From 84a8c0a30792bebf5c6dc5219ae6fe33e7380e41 Mon Sep 17 00:00:00 2001 From: Kavya Agarwal Date: Mon, 4 May 2026 16:41:24 +0530 Subject: [PATCH] feat(ansible): add SnapMirror playbooks for provision, test failover, and cleanup - snapmirror_provision_src_managed.yml: source-managed SnapMirror provisioning - snapmirror_provision_dest_managed.yml: destination-managed SnapMirror provisioning - snapmirror_test_failover.yml: test failover workflow - snapmirror_cleanup_test_failover.yml: cleanup after test failover --- ansible/snapmirror_cleanup_test_failover.yml | 394 ++++++++++ ansible/snapmirror_provision_dest_managed.yml | 709 ++++++++++++++++++ ansible/snapmirror_provision_src_managed.yml | 406 ++++++++++ ansible/snapmirror_test_failover.yml | 383 ++++++++++ 4 files changed, 1892 insertions(+) create mode 100644 ansible/snapmirror_cleanup_test_failover.yml create mode 100644 ansible/snapmirror_provision_dest_managed.yml create mode 100644 ansible/snapmirror_provision_src_managed.yml create mode 100644 ansible/snapmirror_test_failover.yml diff --git a/ansible/snapmirror_cleanup_test_failover.yml b/ansible/snapmirror_cleanup_test_failover.yml new file mode 100644 index 0000000..00ff37a --- /dev/null +++ b/ansible/snapmirror_cleanup_test_failover.yml @@ -0,0 +1,394 @@ +--- +# cleanup_test_failover.yaml — Delete the FlexClone created by test_failover. +# +# Finds the clone via SnapMirror relationship UUID tag (":test"). +# Only clones tagged by the test failover workflow are touched — manually +# created volumes are never matched or deleted. +# +# Phases: +# 0 Relationship-pick — find SM relationship on correct cluster +# A Tag-based find — locate clone tagged with ":test" +# B SMAS removal — delete any SMAS relationship on the clone +# C Unmount — remove NAS junction path (with retry) +# D Offline — set volume state to offline +# E Delete — delete the clone and confirm removal +# +# Prerequisites: +# 1. ONTAP 9.8+ on both clusters +# 2. test_failover must have been run first — this playbook only finds +# clones tagged by that workflow +# 3. The SnapMirror relationship must still be accessible on one cluster +# 4. Admin credentials for both clusters +# +# Credentials are injected via environment variables: +# CLUSTER_A, CLUSTER_B +# DEST_USER, DEST_PASS +# SOURCE_VOLUME, SOURCE_SVM +# +# Usage: +# export CLUSTER_A=10.x.x.x CLUSTER_B=10.y.y.y +# export DEST_USER=admin DEST_PASS=secret +# export SOURCE_VOLUME=vol_rw_01 +# export SOURCE_SVM=vs0 +# ansible-playbook ansible/cleanup_test_failover.yml +# +- name: "SnapMirror — Cleanup Test Failover (Clone Deletion)" + hosts: localhost + gather_facts: false + connection: local + + vars: + cluster_a: "{{ lookup('env', 'CLUSTER_A') }}" + cluster_b: "{{ lookup('env', 'CLUSTER_B') }}" + dest_user: "{{ lookup('env', 'DEST_USER') | default('admin', true) }}" + dest_pass: "{{ lookup('env', 'DEST_PASS') }}" + source_volume: "{{ lookup('env', 'SOURCE_VOLUME') }}" + source_svm: "{{ lookup('env', 'SOURCE_SVM') }}" + source_path: "{{ source_svm }}:{{ source_volume }}" + validate_certs: false + + module_defaults: + ansible.builtin.uri: + force_basic_auth: true + validate_certs: "{{ validate_certs }}" + headers: + Accept: "application/json" + Content-Type: "application/json" + X-Dot-Client-App: "orchestrio" + timeout: 30 + status_code: [200, 201, 202] + + tasks: + # ================================================================ + # Phase 0: Find SnapMirror relationship on correct cluster + # ================================================================ + + - name: "Phase 0 — Validate required environment variables" + ansible.builtin.assert: + that: + - cluster_a | length > 0 + - cluster_b | length > 0 + - dest_pass | length > 0 + - source_volume | length > 0 + - source_svm | length > 0 + fail_msg: >- + Missing env vars. Export CLUSTER_A, CLUSTER_B, DEST_PASS, + SOURCE_VOLUME, SOURCE_SVM. + no_log: false + + - name: "Phase 0 — Try cluster A for SnapMirror relationship" + ansible.builtin.uri: + url: "https://{{ cluster_a }}/api/snapmirror/relationships?source.path={{ source_path }}&fields=uuid,source.path,destination.path,state,healthy&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + timeout: 20 + register: rel_a + failed_when: false + no_log: false + + - name: "Phase 0 — Try cluster B for SnapMirror relationship" + ansible.builtin.uri: + url: "https://{{ cluster_b }}/api/snapmirror/relationships?source.path={{ source_path }}&fields=uuid,source.path,destination.path,state,healthy&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + timeout: 20 + register: rel_b + failed_when: false + when: >- + rel_a.status | default(0) not in [200] + or (rel_a.json.num_records | default(0) | int) == 0 + no_log: false + + - name: "Phase 0 — Determine which cluster owns the relationship" + ansible.builtin.set_fact: + dest_host: >- + {{ cluster_a + if (rel_a.status | default(0) == 200 and (rel_a.json.num_records | default(0) | int) > 0) + else cluster_b }} + sm_rel: >- + {{ rel_a.json.records[0] + if (rel_a.status | default(0) == 200 and (rel_a.json.num_records | default(0) | int) > 0) + else rel_b.json.records[0] }} + + - name: "Phase 0 — Validate a relationship was found" + ansible.builtin.assert: + that: + - sm_rel.uuid is defined + - sm_rel.uuid | length > 0 + fail_msg: >- + No SnapMirror relationship found for {{ source_path }} + on either cluster ({{ cluster_a }}, {{ cluster_b }}). + no_log: false + + - name: "Phase 0 — Store relationship UUID" + ansible.builtin.set_fact: + rel_uuid: "{{ sm_rel.uuid }}" + dest_api: "https://{{ dest_host }}/api" + + - name: "Phase 0 — Log relationship found" + ansible.builtin.debug: + msg: >- + RELATIONSHIP FOUND | cluster={{ dest_host }} + | uuid={{ rel_uuid }} + | source={{ sm_rel.source.path | default('unknown') }} + | dest={{ sm_rel.destination.path | default('unknown') }} + | state={{ sm_rel.state | default('unknown') }} + | healthy={{ sm_rel.healthy | default('unknown') }} + + - name: "Phase 0 — Warn if relationship is not snapmirrored" + ansible.builtin.debug: + msg: >- + WARNING — Relationship state={{ sm_rel.state }} + healthy={{ sm_rel.healthy | default('unknown') }} + — proceeding with cleanup anyway. + when: sm_rel.state | default('') != 'snapmirrored' + + # ================================================================ + # Phase A: Tag-based find — locate clone tagged ":test" + # ================================================================ + + - name: "Phase A — Search for tagged clone volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?_tags={{ rel_uuid }}:test&fields=name,uuid,svm.name,state,nas.path&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: tagged_resp + no_log: false + + - name: "Phase A — Exit cleanly if no tagged clone found" + ansible.builtin.debug: + msg: >- + NO TAGGED CLONE FOUND for {{ source_path }} on {{ dest_host }} + — nothing to clean up. + when: tagged_resp.json.num_records | default(0) | int == 0 + + - name: "Phase A — End play if no clone" + ansible.builtin.meta: end_play + when: tagged_resp.json.num_records | default(0) | int == 0 + + - name: "Phase A — Store clone details" + ansible.builtin.set_fact: + clone_uuid: "{{ tagged_resp.json.records[0].uuid }}" + clone_name: "{{ tagged_resp.json.records[0].name }}" + clone_svm: "{{ tagged_resp.json.records[0].svm.name }}" + + - name: "Phase A — Log clone found" + ansible.builtin.debug: + msg: >- + CLONE FOUND | name={{ clone_name }} + | uuid={{ clone_uuid }} + | svm={{ clone_svm }} + | cluster={{ dest_host }} + + # ================================================================ + # Phase B: SMAS removal — delete SnapMirror relationships on clone + # ================================================================ + + - name: "Phase B — Find SMAS relationships on clone" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?destination.path={{ clone_svm }}:{{ clone_name }}&fields=uuid,state&max_records=10" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: smas_resp + no_log: false + + - name: "Phase B — Log no SMAS relationships" + ansible.builtin.debug: + msg: "No SMAS relationships found on clone — continuing." + when: smas_resp.json.num_records | default(0) | int == 0 + + - name: "Phase B — Delete each SMAS relationship" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ item.uuid }}?return_timeout=120&force=true" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: DELETE + loop: "{{ smas_resp.json.records | default([]) }}" + loop_control: + label: "{{ item.uuid }}" + register: smas_delete + failed_when: false + no_log: false + + - name: "Phase B — Poll SMAS delete jobs" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ item.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + loop: "{{ smas_delete.results | default([]) }}" + loop_control: + label: "{{ item.json.job.uuid | default('n/a') }}" + register: smas_jobs + until: smas_jobs.json.state | default('success') in ['success', 'failure'] + retries: 30 + delay: 10 + when: >- + item.json is defined + and item.json.job is defined + and item.json.job.uuid is defined + failed_when: false + no_log: false + + - name: "Phase B — Bring clone online (in case previous run left it offline)" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes/{{ clone_uuid }}?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: PATCH + body_format: json + body: + state: online + register: bring_online + failed_when: false + no_log: false + + - name: "Phase B — Poll bring-online job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ bring_online.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: online_job + until: online_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + when: >- + bring_online.json is defined + and bring_online.json.job is defined + failed_when: false + no_log: false + + # ================================================================ + # Phase C: Unmount clone — remove NAS junction path (with retry) + # ================================================================ + + - name: "Phase C — Unmount clone (remove junction path)" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes/{{ clone_uuid }}?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: PATCH + body_format: json + body: + nas: + path: "" + register: unmount_result + until: unmount_result.status | default(0) in [200, 201, 202] + retries: 6 + delay: 10 + failed_when: false + no_log: false + + - name: "Phase C — Validate unmount succeeded" + ansible.builtin.assert: + that: + - unmount_result.status | default(0) in [200, 201, 202] + fail_msg: >- + Failed to unmount clone after 6 attempts — aborting. + Last response: {{ unmount_result.msg | default(unmount_result.json | default('unknown')) }} + no_log: false + + - name: "Phase C — Poll unmount job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ unmount_result.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: unmount_job + until: unmount_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + failed_when: unmount_job.json.state == 'failure' + when: >- + unmount_result.json is defined + and unmount_result.json.job is defined + no_log: false + + # ================================================================ + # Phase D: Offline clone + # ================================================================ + + - name: "Phase D — Set clone volume offline" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes/{{ clone_uuid }}?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: PATCH + body_format: json + body: + state: offline + register: offline_result + failed_when: false + no_log: false + + - name: "Phase D — Poll offline job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ offline_result.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: offline_job + until: offline_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + when: >- + offline_result.json is defined + and offline_result.json.job is defined + failed_when: false + no_log: false + + # ================================================================ + # Phase E: Delete clone and confirm removal + # ================================================================ + + - name: "Phase E — Delete clone volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes/{{ clone_uuid }}?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: DELETE + register: delete_result + failed_when: false + no_log: false + + - name: "Phase E — Poll delete job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ delete_result.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: delete_job + until: delete_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + when: >- + delete_result.json is defined + and delete_result.json.job is defined + failed_when: false + no_log: false + + - name: "Phase E — Confirm clone was deleted" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?uuid={{ clone_uuid }}&fields=name,uuid&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: confirm_delete + no_log: false + + - name: "Phase E — Log cleanup success" + ansible.builtin.debug: + msg: >- + === CLEANUP COMPLETE — clone '{{ clone_name }}' deleted + from cluster {{ dest_host }} === + when: confirm_delete.json.num_records | default(0) | int == 0 + + - name: "Phase E — Fail if clone still exists" + ansible.builtin.fail: + msg: "Clone '{{ clone_name }}' still exists after delete attempt." + when: confirm_delete.json.num_records | default(0) | int > 0 diff --git a/ansible/snapmirror_provision_dest_managed.yml b/ansible/snapmirror_provision_dest_managed.yml new file mode 100644 index 0000000..08251bd --- /dev/null +++ b/ansible/snapmirror_provision_dest_managed.yml @@ -0,0 +1,709 @@ +--- +# snapmirror_provision_dest_managed.yaml — Provision SnapMirror (destination-managed). +# +# All SnapMirror API calls are driven from the DESTINATION cluster. +# Source RW volume must already exist; dest DP volume is auto-created. +# +# Phases: +# A Source pre-flight — verify source cluster + volume +# B Dest pre-flight — verify dest cluster connectivity +# B0 Cluster peer setup — auto-create cluster peer if missing +# B1 SVM peer setup — auto-create SVM peer if missing +# C Dest volume setup — auto-create DP volume if missing +# D Relationship setup — create + initialize SnapMirror +# E Convergence polling — poll until state=snapmirrored +# F Final validation — health check + report +# +# Prerequisites: +# 1. ONTAP 9.8+ on both clusters +# 2. SnapMirror licence installed on both clusters +# 3. At least one intercluster LIF on each cluster +# 4. Source RW volume already exists on SOURCE_SVM +# 5. At least one online aggregate on the destination cluster +# 6. Admin credentials for both clusters +# +# Credentials are injected via environment variables: +# SOURCE_HOST, SOURCE_USER, SOURCE_PASS +# DEST_HOST, DEST_USER, DEST_PASS +# SOURCE_SVM, SOURCE_VOLUME, DEST_SVM +# SM_POLICY (optional, default: Asynchronous) +# +# Usage: +# export SOURCE_HOST=10.x.x.x SOURCE_USER=admin SOURCE_PASS=secret +# export SOURCE_SVM=vs1 SOURCE_VOLUME=vol_rw_01 +# export DEST_HOST=10.y.y.y DEST_USER=admin DEST_PASS=secret +# export DEST_SVM=vs0 +# export SM_POLICY=Asynchronous +# ansible-playbook ansible/snapmirror_provision_dest_managed.yml +# +- name: "SnapMirror — Destination-Managed Provisioning" + hosts: localhost + gather_facts: false + connection: local + + vars: + source_host: "{{ lookup('env', 'SOURCE_HOST') }}" + source_user: "{{ lookup('env', 'SOURCE_USER') | default('admin', true) }}" + source_pass: "{{ lookup('env', 'SOURCE_PASS') }}" + dest_host: "{{ lookup('env', 'DEST_HOST') }}" + dest_user: "{{ lookup('env', 'DEST_USER') | default('admin', true) }}" + dest_pass: "{{ lookup('env', 'DEST_PASS') }}" + source_svm: "{{ lookup('env', 'SOURCE_SVM') }}" + source_volume: "{{ lookup('env', 'SOURCE_VOLUME') }}" + dest_svm: "{{ lookup('env', 'DEST_SVM') }}" + dest_volume: "{{ source_volume }}_dest" + sm_policy: "{{ lookup('env', 'SM_POLICY') | default('Asynchronous', true) }}" + validate_certs: false + source_api: "https://{{ source_host }}/api" + dest_api: "https://{{ dest_host }}/api" + + module_defaults: + ansible.builtin.uri: + force_basic_auth: true + validate_certs: "{{ validate_certs }}" + headers: + Accept: "application/json" + Content-Type: "application/json" + X-Dot-Client-App: "orchestrio" + timeout: 30 + status_code: [200, 201, 202] + + tasks: + # ================================================================ + # Phase A: Source pre-flight + # ================================================================ + + - name: "Phase A — Validate required environment variables" + ansible.builtin.assert: + that: + - source_host | length > 0 + - source_pass | length > 0 + - dest_host | length > 0 + - dest_pass | length > 0 + - source_svm | length > 0 + - source_volume | length > 0 + - dest_svm | length > 0 + fail_msg: >- + Missing env vars. Export SOURCE_HOST, SOURCE_PASS, DEST_HOST, + DEST_PASS, SOURCE_SVM, SOURCE_VOLUME, DEST_SVM. + no_log: false + + - name: "Phase A — Get source cluster info" + ansible.builtin.uri: + url: "{{ source_api }}/cluster?fields=name,version" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: GET + register: src_cluster + no_log: false + + - name: "Phase A — Log source cluster" + ansible.builtin.debug: + msg: >- + SOURCE CLUSTER | name={{ src_cluster.json.name }} + | ontap={{ src_cluster.json.version.full }} + + - name: "Phase A — Get source volume info" + ansible.builtin.uri: + url: "{{ source_api }}/storage/volumes?name={{ source_volume }}&svm.name={{ source_svm }}&fields=name,uuid,state,type,space.size&max_records=1" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: GET + register: src_vol_resp + no_log: false + + - name: "Phase A — Validate source volume exists" + ansible.builtin.assert: + that: + - src_vol_resp.json.num_records | int > 0 + fail_msg: >- + ABORTED — source volume '{{ source_volume }}' not found on {{ source_host }}. + no_log: false + + - name: "Phase A — Store source volume record" + ansible.builtin.set_fact: + src_vol: "{{ src_vol_resp.json.records[0] }}" + + - name: "Phase A — Validate source volume is RW (not DP)" + ansible.builtin.assert: + that: + - src_vol.type != 'dp' + fail_msg: "ABORTED — source volume is type=dp; specify the RW volume." + no_log: false + + - name: "Phase A — Log source volume" + ansible.builtin.debug: + msg: >- + SOURCE VOLUME | svm={{ source_svm }} + | name={{ src_vol.name }} + | uuid={{ src_vol.uuid }} + | state={{ src_vol.state }} + | type={{ src_vol.type }} + | size={{ src_vol.space.size | default('unknown') }} + + # ================================================================ + # Phase B: Dest pre-flight + # ================================================================ + + - name: "Phase B — Get destination cluster info" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster?fields=name,version" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: dst_cluster + no_log: false + + - name: "Phase B — Log destination cluster" + ansible.builtin.debug: + msg: >- + DEST CLUSTER | name={{ dst_cluster.json.name }} + | ontap={{ dst_cluster.json.version.full }} + + # ================================================================ + # Phase B0: Cluster peer setup (auto-create if missing) + # ================================================================ + + - name: "Phase B0 — Get intercluster LIFs on source" + ansible.builtin.uri: + url: "{{ source_api }}/network/ip/interfaces?services=intercluster_core&fields=name,ip.address&max_records=50" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: GET + register: src_ic_lifs_resp + no_log: false + + - name: "Phase B0 — Get intercluster LIFs on destination" + ansible.builtin.uri: + url: "{{ dest_api }}/network/ip/interfaces?services=intercluster_core&fields=name,ip.address&max_records=50" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: dst_ic_lifs_resp + no_log: false + + - name: "Phase B0 — Store IC LIF IP lists" + ansible.builtin.set_fact: + src_ic_ips: >- + {{ src_ic_lifs_resp.json.records + | default([]) + | map(attribute='ip') + | map(attribute='address') + | list }} + dst_ic_ips: >- + {{ dst_ic_lifs_resp.json.records + | default([]) + | map(attribute='ip') + | map(attribute='address') + | list }} + + - name: "Phase B0 — Validate source has intercluster LIFs" + ansible.builtin.assert: + that: + - src_ic_ips | length > 0 + fail_msg: >- + ABORTED — Source cluster has no intercluster LIFs. + SnapMirror requires at least one IC LIF on each cluster. + Create one via System Manager: Network > IP Interfaces > Add > Role: Intercluster. + no_log: false + + - name: "Phase B0 — Validate destination has intercluster LIFs" + ansible.builtin.assert: + that: + - dst_ic_ips | length > 0 + fail_msg: >- + ABORTED — Dest cluster has no intercluster LIFs. + SnapMirror requires at least one IC LIF on each cluster. + Create one via System Manager: Network > IP Interfaces > Add > Role: Intercluster. + no_log: false + + - name: "Phase B0 — Log IC LIFs" + ansible.builtin.debug: + msg: "IC LIFs | src={{ src_ic_ips }} dst={{ dst_ic_ips }}" + + - name: "Phase B0 — Warn if IC LIF subnets differ" + ansible.builtin.debug: + msg: >- + PRE-CONDITION WARNING | IC LIFs may be on different subnets. + SnapMirror data transfers require TCP 11104 and 11105 to be open + between subnets. If not routed, transfers will fail with error 13303812. + when: >- + (src_ic_ips[0] | default('0.0.0.0')).split('.')[:3] | join('.') + != (dst_ic_ips[0] | default('0.0.0.0')).split('.')[:3] | join('.') + + - name: "Phase B0 — Get cluster peers on destination" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/peers?fields=name,uuid,status.state&max_records=10" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: dst_cluster_peers + no_log: false + + - name: "Phase B0 — Get cluster peers on source" + ansible.builtin.uri: + url: "{{ source_api }}/cluster/peers?fields=name,uuid,status.state&max_records=10" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: GET + register: src_cluster_peers + no_log: false + + - name: "Phase B0 — Store existing peer info" + ansible.builtin.set_fact: + dst_peers_ok: >- + {{ dst_cluster_peers.json.records + | default([]) + | selectattr('status.state', 'in', ['available', 'partial', 'pending']) + | list }} + src_peers_ok: >- + {{ src_cluster_peers.json.records + | default([]) + | selectattr('status.state', 'in', ['available', 'partial', 'pending']) + | list }} + + - name: "Phase B0 — Log existing cluster peer (skip create)" + ansible.builtin.debug: + msg: >- + CLUSTER PEER | already peered — dst sees src as + '{{ dst_peers_ok[0].name }}' (state={{ dst_peers_ok[0].status.state }}) — skipping + when: dst_peers_ok | length > 0 + + - name: "Phase B0 — Auto-create cluster peer on source" + ansible.builtin.uri: + url: "{{ source_api }}/cluster/peers" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: POST + body_format: json + body: + peer_addresses: "{{ dst_ic_ips }}" + generate_passphrase: true + encryption: + proposed: tls-psk + initial_allowed_svms: + - name: "{{ source_svm }}" + register: src_peer_create + when: dst_peers_ok | length == 0 + no_log: false + + - name: "Phase B0 — Accept cluster peer on destination" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/peers" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: + peer_addresses: "{{ src_ic_ips }}" + passphrase: "{{ src_peer_create.json.passphrase }}" + initial_allowed_svms: + - name: "{{ dest_svm }}" + when: dst_peers_ok | length == 0 + no_log: false + + - name: "Phase B0 — Wait for peer to settle" + ansible.builtin.pause: + seconds: 5 + when: dst_peers_ok | length == 0 + + - name: "Phase B0 — Refresh cluster peers on destination" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/peers?fields=name,uuid,status.state&max_records=10" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: dst_cluster_peers_final + no_log: false + + - name: "Phase B0 — Refresh cluster peers on source" + ansible.builtin.uri: + url: "{{ source_api }}/cluster/peers?fields=name,uuid,status.state&max_records=10" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: GET + register: src_cluster_peers_final + no_log: false + + - name: "Phase B0 — Store final peer names" + ansible.builtin.set_fact: + dst_peers_final: >- + {{ dst_cluster_peers_final.json.records + | default([]) + | selectattr('status.state', 'in', ['available', 'partial', 'pending']) + | list }} + src_peers_final: >- + {{ src_cluster_peers_final.json.records + | default([]) + | selectattr('status.state', 'in', ['available', 'partial', 'pending']) + | list }} + + - name: "Phase B0 — Set peer_name and src_peer_name" + ansible.builtin.set_fact: + peer_name: "{{ dst_peers_final[0].name | default('') }}" + dst_peer_uuid: "{{ dst_peers_final[0].uuid | default('') }}" + src_peer_name: "{{ src_peers_final[0].name | default('') }}" + + - name: "Phase B0 — Log cluster peer names" + ansible.builtin.debug: + msg: "CLUSTER PEER | dst sees src as '{{ peer_name }}'" + + # ================================================================ + # Phase B — Get aggregate + # ================================================================ + + - name: "Phase B — Auto-select first aggregate on destination" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/aggregates?fields=name,state&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: aggr_resp + no_log: false + + - name: "Phase B — Store aggregate name" + ansible.builtin.set_fact: + aggr_name: "{{ aggr_resp.json.records[0].name | default('') }}" + + - name: "Phase B — Validate an aggregate was found" + ansible.builtin.assert: + that: + - aggr_name | length > 0 + fail_msg: "ABORTED — no aggregate found on destination cluster." + no_log: false + + - name: "Phase B — Log destination aggregate" + ansible.builtin.debug: + msg: "DEST AGGREGATE | name={{ aggr_name }}" + + # ================================================================ + # Phase B1: SVM peer setup (auto-create if missing) + # ================================================================ + + - name: "Phase B1 — Get SVM peers on destination" + ansible.builtin.uri: + url: "{{ dest_api }}/svm/peers?svm.name={{ dest_svm }}&fields=uuid,name,state,peer&max_records=10" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: svm_peers_resp + no_log: false + + - name: "Phase B1 — Check for existing SVM peer" + ansible.builtin.set_fact: + svm_peers_ok: >- + {{ svm_peers_resp.json.records + | default([]) + | selectattr('state', 'in', ['peered', 'initiated']) + | list }} + + - name: "Phase B1 — Store source SVM alias (existing peer)" + ansible.builtin.set_fact: + source_svm_alias: >- + {{ svm_peers_ok[0].peer.svm.name | default(source_svm) }} + when: svm_peers_ok | length > 0 + + - name: "Phase B1 — Log existing SVM peer (skip create)" + ansible.builtin.debug: + msg: >- + SVM PEER | already peered '{{ dest_svm }}' <-> '{{ source_svm }}' + (alias='{{ source_svm_alias }}', state={{ svm_peers_ok[0].state }}) — skipping + when: svm_peers_ok | length > 0 + + - name: "Phase B1 — Grant SVM peer-permission on source" + ansible.builtin.uri: + url: "{{ source_api }}/svm/peer-permissions" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: POST + body_format: json + body: + svm: + name: "{{ source_svm }}" + cluster_peer: + name: "{{ src_peer_name }}" + applications: + - snapmirror + status_code: [200, 201, 202, 409] + register: svm_perm + when: svm_peers_ok | length == 0 + no_log: false + + - name: "Phase B1 — Create SVM peer from destination" + ansible.builtin.uri: + url: "{{ dest_api }}/svm/peers" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: + svm: + name: "{{ dest_svm }}" + peer: + svm: + name: "{{ source_svm }}" + cluster: + name: "{{ peer_name }}" + applications: + - snapmirror + status_code: [200, 201, 202, 409] + register: svm_peer_create + when: svm_peers_ok | length == 0 + no_log: false + + - name: "Phase B1 — Poll SVM peer creation job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ svm_peer_create.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: svm_peer_job + until: svm_peer_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + failed_when: svm_peer_job.json.state == 'failure' + when: >- + svm_peers_ok | length == 0 + and svm_peer_create is not skipped + and svm_peer_create.json.job is defined + no_log: false + + - name: "Phase B1 — Refresh SVM peers for alias lookup" + ansible.builtin.uri: + url: "{{ dest_api }}/svm/peers?svm.name={{ dest_svm }}&fields=uuid,name,state,peer&max_records=10" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: svm_peers_final + when: svm_peers_ok | length == 0 + no_log: false + + - name: "Phase B1 — Store source SVM alias (new peer)" + ansible.builtin.set_fact: + source_svm_alias: >- + {{ (svm_peers_final.json.records | default([]))[0].peer.svm.name + | default(source_svm) }} + when: svm_peers_ok | length == 0 + + - name: "Phase B1 — Log SVM peer created" + ansible.builtin.debug: + msg: "SVM PEER | created '{{ dest_svm }}' <-> '{{ source_svm }}'" + when: svm_peers_ok | length == 0 + + # ================================================================ + # Phase C: Dest volume setup (auto-create DP if missing) + # ================================================================ + + - name: "Phase C — Check if destination DP volume already exists" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?name={{ dest_volume }}&svm.name={{ dest_svm }}&fields=name,uuid,state,type&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: check_dest + no_log: false + + - name: "Phase C — Create dest DP volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: + name: "{{ dest_volume }}" + type: dp + svm: + name: "{{ dest_svm }}" + aggregates: + - name: "{{ aggr_name }}" + space: + size: "{{ src_vol.space.size }}" + register: create_vol + when: check_dest.json.num_records | int == 0 + failed_when: false + no_log: false + + - name: "Phase C — Log volume create result" + ansible.builtin.debug: + msg: >- + {{ 'DEST VOLUME | created' if create_vol.status | default(0) in [200, 201, 202] + else 'create_dest_volume — skipped (may already exist)' }} + when: check_dest.json.num_records | int == 0 + + - name: "Phase C — Log skip when volume already exists" + ansible.builtin.debug: + msg: "Dest volume '{{ dest_volume }}' already exists — skipping create." + when: check_dest.json.num_records | int > 0 + + - name: "Phase C — Verify destination volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?name={{ dest_volume }}&svm.name={{ dest_svm }}&fields=name,uuid,state,type&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: dst_vol_resp + failed_when: dst_vol_resp.json.num_records | int == 0 + no_log: false + + - name: "Phase C — Store destination volume record" + ansible.builtin.set_fact: + dst_vol: "{{ dst_vol_resp.json.records[0] }}" + + - name: "Phase C — Log destination volume" + ansible.builtin.debug: + msg: >- + DEST VOLUME | svm={{ dest_svm }} + | name={{ dst_vol.name }} + | uuid={{ dst_vol.uuid }} + | state={{ dst_vol.state }} + | type={{ dst_vol.type }} + + # ================================================================ + # Phase D: Relationship setup (create + initialize) + # ================================================================ + + - name: "Phase D — Check if SnapMirror relationship already exists" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?destination.path={{ dest_svm }}:{{ dest_volume }}&fields=uuid,state,healthy&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: existing_rel + no_log: false + + - name: "Phase D — Log relationship check" + ansible.builtin.debug: + msg: "RELATIONSHIP CHECK | existing={{ existing_rel.json.num_records }}" + + - name: "Phase D — Create SnapMirror relationship" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: + source: + path: "{{ source_svm_alias }}:{{ source_volume }}" + cluster: + name: "{{ peer_name }}" + destination: + path: "{{ dest_svm }}:{{ dest_volume }}" + policy: + name: "{{ sm_policy }}" + register: sm_create + when: existing_rel.json.num_records | int == 0 + failed_when: false + no_log: false + + - name: "Phase D — Poll relationship creation job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ sm_create.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: sm_job + until: sm_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + failed_when: sm_job.json.state == 'failure' + when: >- + sm_create is not skipped + and sm_create.json is defined + and sm_create.json.job is defined + no_log: false + + # ================================================================ + # Phase E: Convergence polling + # ================================================================ + + - name: "Phase E — Look up SnapMirror relationship" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?destination.path={{ dest_svm }}:{{ dest_volume }}&fields=uuid,source.path,destination.path,state,lag_time,healthy,policy.name&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: rel_resp + failed_when: rel_resp.json.num_records | int == 0 + no_log: false + + - name: "Phase E — Store relationship UUID" + ansible.builtin.set_fact: + rel_uuid: "{{ rel_resp.json.records[0].uuid }}" + + - name: "Phase E — Log relationship found" + ansible.builtin.debug: + msg: >- + RELATIONSHIP | uuid={{ rel_uuid }} + | state={{ rel_resp.json.records[0].state }} + | healthy={{ rel_resp.json.records[0].healthy | default('unknown') }} + | policy={{ rel_resp.json.records[0].policy.name | default('unknown') }} + + - name: "Phase E — Trigger baseline transfer" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}/transfers?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: {} + register: sm_transfer + failed_when: false + no_log: false + + - name: "Phase E — Abort on IC LIF connectivity failure (error 13303812)" + ansible.builtin.fail: + msg: >- + ABORTED — SnapMirror initialize failed: intercluster LIF connectivity issue. + Error: {{ sm_transfer.json | default(sm_transfer.msg | default('unknown')) }} + src IC: {{ src_ic_ips }} dst IC: {{ dst_ic_ips }} + Cause: TCP ports 11104/11105 are likely blocked between these IPs. + Fix: Ask your lab admin to open TCP 11104 and 11105 between the subnets. + when: >- + sm_transfer.status | default(0) not in [200, 201, 202] + and '13303812' in (sm_transfer.json | default({}) | string) + + - name: "Phase E — Log transfer result" + ansible.builtin.debug: + msg: >- + Baseline transfer {{ 'triggered' if sm_transfer.status | default(0) in [200, 201, 202] + else 'skipped (may already be initialized)' }}. + + - name: "Phase E — Poll until relationship is snapmirrored" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}?fields=state,lag_time,healthy" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: sm_status + until: sm_status.json.state == 'snapmirrored' + retries: 120 + delay: 15 + no_log: false + + # ================================================================ + # Phase F: Final validation + # ================================================================ + + - name: "Phase F — Get final relationship state" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}?fields=uuid,source.path,destination.path,state,lag_time,healthy,policy.name" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: final + no_log: false + + - name: "Phase F — Print provisioning summary" + ansible.builtin.debug: + msg: | + === SNAPMIRROR PROVISION COMPLETE === + + Source: {{ source_svm }}:{{ source_volume }} @ {{ source_host }} ({{ src_cluster.json.version.full }}) + Destination: {{ dest_svm }}:{{ dest_volume }} @ {{ dest_host }} ({{ dst_cluster.json.version.full }}) + Aggregate: {{ aggr_name }} + Policy: {{ final.json.policy.name | default(sm_policy) }} + State: {{ final.json.state }} + Healthy: {{ final.json.healthy }} + Lag time: {{ final.json.lag_time | default('N/A') }} diff --git a/ansible/snapmirror_provision_src_managed.yml b/ansible/snapmirror_provision_src_managed.yml new file mode 100644 index 0000000..a1c9bc8 --- /dev/null +++ b/ansible/snapmirror_provision_src_managed.yml @@ -0,0 +1,406 @@ +--- +# snapmirror_provision_src_managed.yaml — Provision SnapMirror (source-managed view). +# +# Connects to BOTH clusters for pre-flight verification, then drives all +# relationship/volume API calls from the DESTINATION cluster (ONTAP requirement). +# +# Phases: +# A Source pre-flight — verify source cluster + volume +# B Dest pre-flight — verify dest cluster + aggregate (auto-selected) +# C Dest volume — auto-create DP volume if missing +# D Relationship — create + initialize SnapMirror +# E Convergence — poll until state=snapmirrored +# F Validation — health check + final report +# +# Prerequisites: +# 1. ONTAP 9.8+ on both clusters +# 2. SnapMirror licence installed on both clusters +# 3. At least one intercluster LIF on each cluster +# 4. Cluster peer relationship already exists +# 5. SVM peer relationship already exists (source SVM <-> dest SVM) +# 6. Source RW volume already exists on SOURCE_SVM +# 7. At least one online aggregate on the destination cluster +# 8. Admin credentials for both clusters +# +# Credentials are injected via environment variables: +# SOURCE_HOST, SOURCE_USER, SOURCE_PASS +# DEST_HOST, DEST_USER, DEST_PASS +# SOURCE_SVM, SOURCE_VOLUME, DEST_SVM +# SM_POLICY (optional, default: Asynchronous) +# +# Usage: +# export SOURCE_HOST=10.x.x.x SOURCE_USER=admin SOURCE_PASS=secret +# export SOURCE_SVM=vs0 SOURCE_VOLUME=vol_rw_01 +# export DEST_HOST=10.y.y.y DEST_USER=admin DEST_PASS=secret +# export DEST_SVM=vs1 +# export SM_POLICY=Asynchronous +# ansible-playbook ansible/snapmirror_provision_src_managed.yml +# +- name: "SnapMirror — Source-Managed Provisioning" + hosts: localhost + gather_facts: false + connection: local + + vars: + source_host: "{{ lookup('env', 'SOURCE_HOST') }}" + source_user: "{{ lookup('env', 'SOURCE_USER') | default('admin', true) }}" + source_pass: "{{ lookup('env', 'SOURCE_PASS') }}" + dest_host: "{{ lookup('env', 'DEST_HOST') }}" + dest_user: "{{ lookup('env', 'DEST_USER') | default('admin', true) }}" + dest_pass: "{{ lookup('env', 'DEST_PASS') }}" + source_svm: "{{ lookup('env', 'SOURCE_SVM') }}" + source_volume: "{{ lookup('env', 'SOURCE_VOLUME') }}" + dest_svm: "{{ lookup('env', 'DEST_SVM') }}" + dest_volume: "{{ source_volume }}_dest" + sm_policy: "{{ lookup('env', 'SM_POLICY') | default('Asynchronous', true) }}" + validate_certs: false + source_api: "https://{{ source_host }}/api" + dest_api: "https://{{ dest_host }}/api" + + module_defaults: + ansible.builtin.uri: + force_basic_auth: true + validate_certs: "{{ validate_certs }}" + headers: + Accept: "application/json" + Content-Type: "application/json" + X-Dot-Client-App: "orchestrio" + timeout: 30 + status_code: [200, 201, 202] + + tasks: + # ================================================================ + # Phase A: Source pre-flight + # ================================================================ + + - name: "Phase A — Validate required environment variables" + ansible.builtin.assert: + that: + - source_host | length > 0 + - source_pass | length > 0 + - dest_host | length > 0 + - dest_pass | length > 0 + - source_svm | length > 0 + - source_volume | length > 0 + - dest_svm | length > 0 + fail_msg: >- + Missing env vars. Export SOURCE_HOST, SOURCE_PASS, DEST_HOST, + DEST_PASS, SOURCE_SVM, SOURCE_VOLUME, DEST_SVM. + no_log: false + + - name: "Phase A — Get source cluster info" + ansible.builtin.uri: + url: "{{ source_api }}/cluster?fields=name,version" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: GET + register: src_cluster + no_log: false + + - name: "Phase A — Log source cluster" + ansible.builtin.debug: + msg: >- + SOURCE CLUSTER | name={{ src_cluster.json.name }} + | ontap={{ src_cluster.json.version.full }} + + - name: "Phase A — Get source volume info" + ansible.builtin.uri: + url: "{{ source_api }}/storage/volumes?name={{ source_volume }}&svm.name={{ source_svm }}&fields=name,uuid,state,type,space.size&max_records=1" + url_username: "{{ source_user }}" + url_password: "{{ source_pass }}" + method: GET + register: src_vol_resp + no_log: false + + - name: "Phase A — Validate source volume exists" + ansible.builtin.assert: + that: + - src_vol_resp.json.num_records | int > 0 + fail_msg: >- + ABORTED — source volume '{{ source_volume }}' not found on {{ source_host }}. + no_log: false + + - name: "Phase A — Store source volume record" + ansible.builtin.set_fact: + src_vol: "{{ src_vol_resp.json.records[0] }}" + + - name: "Phase A — Validate source volume is RW (not DP)" + ansible.builtin.assert: + that: + - src_vol.type != 'dp' + fail_msg: "ABORTED — source volume is type=dp; specify the RW volume." + no_log: false + + - name: "Phase A — Log source volume" + ansible.builtin.debug: + msg: >- + SOURCE VOLUME | name={{ src_vol.name }} + | uuid={{ src_vol.uuid }} + | state={{ src_vol.state }} + | type={{ src_vol.type }} + | size={{ src_vol.space.size | default('unknown') }} + + # ================================================================ + # Phase B: Dest pre-flight + # ================================================================ + + - name: "Phase B — Get destination cluster info" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster?fields=name,version" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: dst_cluster + no_log: false + + - name: "Phase B — Log destination cluster" + ansible.builtin.debug: + msg: >- + DEST CLUSTER | name={{ dst_cluster.json.name }} + | ontap={{ dst_cluster.json.version.full }} + + - name: "Phase B — Get cluster peer name from destination" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/peers?fields=name,status.state&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: peer_resp + no_log: false + + - name: "Phase B — Store cluster peer name" + ansible.builtin.set_fact: + peer_name: "{{ peer_resp.json.records[0].name | default('') }}" + + - name: "Phase B — Log cluster peer" + ansible.builtin.debug: + msg: "CLUSTER PEER | name={{ peer_name }}" + + - name: "Phase B — Auto-select largest online aggregate on destination" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/aggregates?state=online&fields=name,space.block_storage.available&order_by=space.block_storage.available+desc&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: aggr_resp + no_log: false + + - name: "Phase B — Store aggregate name" + ansible.builtin.set_fact: + aggr_name: "{{ aggr_resp.json.records[0].name | default('') }}" + + - name: "Phase B — Validate an online aggregate was found" + ansible.builtin.assert: + that: + - aggr_name | length > 0 + fail_msg: "ABORTED — no online aggregate found on destination cluster." + no_log: false + + - name: "Phase B — Log destination aggregate" + ansible.builtin.debug: + msg: "DEST AGGREGATE | name={{ aggr_name }}" + + # ================================================================ + # Phase C: Dest volume setup (auto-create DP if missing) + # ================================================================ + + - name: "Phase C — Check if destination DP volume already exists" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?name={{ dest_volume }}&svm.name={{ dest_svm }}&fields=name,uuid,state,type&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: check_dest + no_log: false + + - name: "Phase C — Create dest DP volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: + name: "{{ dest_volume }}" + type: dp + svm: + name: "{{ dest_svm }}" + aggregates: + - name: "{{ aggr_name }}" + size: "{{ src_vol.space.size }}" + register: create_vol + when: check_dest.json.num_records | int == 0 + no_log: false + + - name: "Phase C — Poll volume creation job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ create_vol.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: vol_job + until: vol_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + failed_when: vol_job.json.state == 'failure' + when: create_vol is not skipped and create_vol.json.job is defined + no_log: false + + - name: "Phase C — Log skip when volume already exists" + ansible.builtin.debug: + msg: "Dest volume '{{ dest_volume }}' already exists — skipping create." + when: check_dest.json.num_records | int > 0 + + - name: "Phase C — Verify destination volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?name={{ dest_volume }}&svm.name={{ dest_svm }}&fields=name,uuid,state,type&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: dst_vol_resp + failed_when: dst_vol_resp.json.num_records | int == 0 + no_log: false + + - name: "Phase C — Store destination volume record" + ansible.builtin.set_fact: + dst_vol: "{{ dst_vol_resp.json.records[0] }}" + + - name: "Phase C — Log destination volume" + ansible.builtin.debug: + msg: >- + DEST VOLUME | name={{ dst_vol.name }} + | uuid={{ dst_vol.uuid }} + | state={{ dst_vol.state }} + | type={{ dst_vol.type }} + + # ================================================================ + # Phase D: Relationship setup (create + initialize) + # ================================================================ + + - name: "Phase D — Check if SnapMirror relationship already exists" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?destination.path={{ dest_svm }}:{{ dest_volume }}&fields=uuid,state,healthy&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: existing_rel + no_log: false + + - name: "Phase D — Log relationship check" + ansible.builtin.debug: + msg: "RELATIONSHIP CHECK | existing={{ existing_rel.json.num_records }}" + + - name: "Phase D — Create SnapMirror relationship" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: + source: + path: "{{ source_svm }}:{{ source_volume }}" + cluster: + name: "{{ peer_name }}" + destination: + path: "{{ dest_svm }}:{{ dest_volume }}" + policy: + name: "{{ sm_policy }}" + register: sm_create + when: existing_rel.json.num_records | int == 0 + no_log: false + + - name: "Phase D — Poll relationship creation job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ sm_create.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: sm_job + until: sm_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + failed_when: sm_job.json.state == 'failure' + when: sm_create is not skipped and sm_create.json.job is defined + no_log: false + + # ================================================================ + # Phase E: Convergence polling + # ================================================================ + + - name: "Phase E — Look up SnapMirror relationship UUID" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?destination.path={{ dest_svm }}:{{ dest_volume }}&fields=uuid,source.path,destination.path,state,lag_time,healthy,policy.name&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: rel_resp + failed_when: rel_resp.json.num_records | int == 0 + no_log: false + + - name: "Phase E — Store relationship UUID" + ansible.builtin.set_fact: + rel_uuid: "{{ rel_resp.json.records[0].uuid }}" + + - name: "Phase E — Log relationship found" + ansible.builtin.debug: + msg: >- + RELATIONSHIP FOUND | uuid={{ rel_uuid }} + | state={{ rel_resp.json.records[0].state }} + | healthy={{ rel_resp.json.records[0].healthy | default('unknown') }} + + - name: "Phase E — Trigger baseline transfer" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}/transfers?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: {} + register: sm_transfer + failed_when: false + no_log: false + + - name: "Phase E — Log transfer result" + ansible.builtin.debug: + msg: >- + Baseline transfer {{ 'triggered' if sm_transfer.status in [200, 201, 202] + else 'skipped (may already be initialized)' }}. + + - name: "Phase E — Poll until relationship is snapmirrored" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}?fields=state,lag_time,healthy" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: sm_status + until: sm_status.json.state == 'snapmirrored' + retries: 120 + delay: 15 + no_log: false + + # ================================================================ + # Phase F: Final validation + # ================================================================ + + - name: "Phase F — Get final relationship state" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}?fields=uuid,source.path,destination.path,state,lag_time,healthy,policy.name" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: final + no_log: false + + - name: "Phase F — Print provisioning summary" + ansible.builtin.debug: + msg: | + === SNAPMIRROR PROVISION COMPLETE === + + Source: {{ source_svm }}:{{ source_volume }} @ {{ source_host }} ({{ src_cluster.json.version.full }}) + Destination: {{ dest_svm }}:{{ dest_volume }} @ {{ dest_host }} ({{ dst_cluster.json.version.full }}) + Aggregate: {{ aggr_name }} + Policy: {{ final.json.policy.name | default(sm_policy) }} + State: {{ final.json.state }} + Healthy: {{ final.json.healthy }} + Lag time: {{ final.json.lag_time | default('N/A') }} diff --git a/ansible/snapmirror_test_failover.yml b/ansible/snapmirror_test_failover.yml new file mode 100644 index 0000000..7a48c7d --- /dev/null +++ b/ansible/snapmirror_test_failover.yml @@ -0,0 +1,383 @@ +--- +# test_failover.yaml — Create a writable FlexClone of a SnapMirror dest volume. +# +# AUTO mode (SOURCE_VOLUME=* or unset): +# Queries both clusters, picks the one with the most recently created DP volume. +# TARGETED mode (SOURCE_VOLUME=vol_rw_01): +# Finds vol_rw_01_dest on either cluster. +# +# Phases: +# 0 Auto-detect which cluster has the target DP volume +# A Pre-flight — verify cluster + relationship health +# B Snapshot — get latest SnapMirror snapshot on dest volume +# C Clone — create writable FlexClone +# D Verify — confirm clone online + tag with SM relationship UUID +# E Resync — resync SnapMirror + validate healthy state +# +# Prerequisites: +# 1. ONTAP 9.8+ on both clusters +# 2. A healthy SnapMirror relationship must already exist +# 3. Relationship state must be 'snapmirrored' (baseline transfer complete) +# 4. At least one SnapMirror snapshot on the destination volume +# 5. Admin credentials for both clusters +# +# Credentials are injected via environment variables: +# CLUSTER_A, CLUSTER_B +# DEST_USER, DEST_PASS +# SOURCE_VOLUME (optional, default: * for auto-detect) +# +# Usage: +# export CLUSTER_A=10.x.x.x CLUSTER_B=10.y.y.y +# export DEST_USER=admin DEST_PASS=secret +# export SOURCE_VOLUME=* +# ansible-playbook ansible/test_failover.yml +# +- name: "SnapMirror — Test Failover (FlexClone)" + hosts: localhost + gather_facts: false + connection: local + + vars: + cluster_a: "{{ lookup('env', 'CLUSTER_A') }}" + cluster_b: "{{ lookup('env', 'CLUSTER_B') }}" + dest_user: "{{ lookup('env', 'DEST_USER') | default('admin', true) }}" + dest_pass: "{{ lookup('env', 'DEST_PASS') }}" + source_volume: "{{ lookup('env', 'SOURCE_VOLUME') | default('*', true) }}" + dest_vol_filter: >- + {{ (source_volume ~ '_dest') if source_volume != '*' else '*_dest' }} + validate_certs: false + + module_defaults: + ansible.builtin.uri: + force_basic_auth: true + validate_certs: "{{ validate_certs }}" + headers: + Accept: "application/json" + Content-Type: "application/json" + X-Dot-Client-App: "orchestrio" + timeout: 30 + status_code: [200, 201, 202] + + tasks: + # ================================================================ + # Phase 0: Auto-detect target cluster + # ================================================================ + + - name: "Phase 0 — Validate required environment variables" + ansible.builtin.assert: + that: + - cluster_a | length > 0 + - cluster_b | length > 0 + - dest_pass | length > 0 + fail_msg: >- + Missing env vars. Export CLUSTER_A, CLUSTER_B, DEST_PASS. + no_log: false + + - name: "Phase 0 — Search cluster A for DP volume" + ansible.builtin.uri: + url: "https://{{ cluster_a }}/api/storage/volumes?type=dp&name={{ dest_vol_filter }}&fields=name,create_time,uuid,svm.name,state,space.size&order_by=create_time+desc&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + timeout: 20 + register: vol_a + failed_when: false + no_log: false + + - name: "Phase 0 — Search cluster B for DP volume" + ansible.builtin.uri: + url: "https://{{ cluster_b }}/api/storage/volumes?type=dp&name={{ dest_vol_filter }}&fields=name,create_time,uuid,svm.name,state,space.size&order_by=create_time+desc&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + timeout: 20 + register: vol_b + failed_when: false + when: >- + vol_a.status | default(0) not in [200] + or (vol_a.json.num_records | default(0) | int) == 0 + no_log: false + + - name: "Phase 0 — Determine which cluster has the DP volume" + ansible.builtin.set_fact: + dest_host: >- + {{ cluster_a + if (vol_a.status | default(0) == 200 and (vol_a.json.num_records | default(0) | int) > 0) + else cluster_b }} + dp_vol: >- + {{ vol_a.json.records[0] + if (vol_a.status | default(0) == 200 and (vol_a.json.num_records | default(0) | int) > 0) + else vol_b.json.records[0] }} + + - name: "Phase 0 — Validate a DP volume was found" + ansible.builtin.assert: + that: + - dp_vol.uuid is defined + - dp_vol.uuid | length > 0 + fail_msg: >- + No DP volumes found on either cluster ({{ cluster_a }}, {{ cluster_b }}). + no_log: false + + - name: "Phase 0 — Store DP volume details" + ansible.builtin.set_fact: + dest_api: "https://{{ dest_host }}/api" + dp_vol_name: "{{ dp_vol.name }}" + dp_vol_uuid: "{{ dp_vol.uuid }}" + dp_svm_name: "{{ dp_vol.svm.name }}" + clone_name: "{{ dp_vol.name }}_clone" + + - name: "Phase 0 — Log selected target" + ansible.builtin.debug: + msg: >- + SELECTED | cluster={{ dest_host }} + | volume={{ dp_vol_name }} + | svm={{ dp_svm_name }} + | uuid={{ dp_vol_uuid }} + | state={{ dp_vol.state | default('unknown') }} + | size={{ dp_vol.space.size | default('unknown') }} + + # ================================================================ + # Phase A: Pre-flight — verify cluster + relationship health + # ================================================================ + + - name: "Phase A — Get destination cluster info" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster?fields=name,version" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: cluster_info + no_log: false + + - name: "Phase A — Log destination cluster" + ansible.builtin.debug: + msg: >- + DEST CLUSTER | name={{ cluster_info.json.name }} + | ontap={{ cluster_info.json.version.full }} + + - name: "Phase A — Get SnapMirror relationship for DP volume" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships?destination.path={{ dp_svm_name }}:{{ dp_vol_name }}&fields=uuid,source.path,destination.path,state,lag_time,healthy,policy.name&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: rel_resp + failed_when: rel_resp.json.num_records | default(0) | int == 0 + no_log: false + + - name: "Phase A — Store relationship details" + ansible.builtin.set_fact: + rel: "{{ rel_resp.json.records[0] }}" + rel_uuid: "{{ rel_resp.json.records[0].uuid }}" + + - name: "Phase A — Log relationship" + ansible.builtin.debug: + msg: >- + RELATIONSHIP | uuid={{ rel_uuid }} + | source={{ rel.source.path | default('unknown') }} + | dest={{ rel.destination.path | default('unknown') }} + | state={{ rel.state | default('unknown') }} + | healthy={{ rel.healthy | default('unknown') }} + | lag={{ rel.lag_time | default('unknown') }} + + # ================================================================ + # Phase B: Get latest SnapMirror snapshot + # ================================================================ + + - name: "Phase B — Fetch latest snapshot on DP volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes/{{ dp_vol_uuid }}/snapshots?fields=name,create_time&order_by=create_time+desc&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: snap_resp + no_log: false + + - name: "Phase B — Validate snapshot exists" + ansible.builtin.assert: + that: + - snap_resp.json.num_records | default(0) | int > 0 + fail_msg: >- + No SnapMirror snapshots on {{ dp_vol_name }} + — run provision workflow first. + no_log: false + + - name: "Phase B — Store snapshot name" + ansible.builtin.set_fact: + snapshot_name: "{{ snap_resp.json.records[0].name }}" + + - name: "Phase B — Log snapshot" + ansible.builtin.debug: + msg: >- + LATEST SM SNAPSHOT | name={{ snapshot_name }} + | created={{ snap_resp.json.records[0].create_time | default('unknown') }} + + # ================================================================ + # Phase C: Create FlexClone + # ================================================================ + + - name: "Phase C — Create writable FlexClone" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: POST + body_format: json + body: + name: "{{ clone_name }}" + svm: + name: "{{ dp_svm_name }}" + nas: + path: "/{{ clone_name }}" + clone: + is_flexclone: true + parent_volume: + name: "{{ dp_vol_name }}" + parent_snapshot: + name: "{{ snapshot_name }}" + register: clone_create + failed_when: false + no_log: false + + - name: "Phase C — Poll clone creation job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ clone_create.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: clone_job + until: clone_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + failed_when: clone_job.json.state == 'failure' + when: >- + clone_create.json is defined + and clone_create.json.job is defined + no_log: false + + - name: "Phase C — Log clone create result" + ansible.builtin.debug: + msg: >- + {{ 'FlexClone created' if clone_create.status | default(0) in [200, 201, 202] + else 'create_test_clone — may already exist (continuing)' }} + + # ================================================================ + # Phase D: Verify clone + tag with SM relationship UUID + # ================================================================ + + - name: "Phase D — Look up clone volume" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes?name={{ clone_name }}&svm.name={{ dp_svm_name }}&fields=name,uuid,state,nas.path,space.size&max_records=1" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: clone_vol_resp + failed_when: clone_vol_resp.json.num_records | default(0) | int == 0 + no_log: false + + - name: "Phase D — Store clone details" + ansible.builtin.set_fact: + clone_vol: "{{ clone_vol_resp.json.records[0] }}" + clone_uuid: "{{ clone_vol_resp.json.records[0].uuid }}" + + - name: "Phase D — Log clone info" + ansible.builtin.debug: + msg: >- + CLONE | name={{ clone_vol.name }} + | uuid={{ clone_uuid }} + | state={{ clone_vol.state }} + | junction={{ clone_vol.nas.path | default('none') }} + + - name: "Phase D — Tag clone with SM relationship UUID" + ansible.builtin.uri: + url: "{{ dest_api }}/storage/volumes/{{ clone_uuid }}?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: PATCH + body_format: json + body: + _tags: + - "{{ rel_uuid }}:test" + register: tag_result + failed_when: false + no_log: false + + - name: "Phase D — Log tag result" + ansible.builtin.debug: + msg: >- + TAG {{ 'APPLIED' if tag_result.status | default(0) in [200, 201, 202] + else 'FAILED (non-critical)' }} + | clone={{ clone_name }} | tag={{ rel_uuid }}:test + + - name: "Phase D — Print test failover ready summary" + ansible.builtin.debug: + msg: | + === TEST FAILOVER READY === + + Clone : {{ clone_vol.name }} + UUID : {{ clone_uuid }} + State : {{ clone_vol.state }} + Junction : {{ clone_vol.nas.path | default('none') }} + SVM : {{ dp_svm_name }} + Snapshot : {{ snapshot_name }} + + ACTION: Mount {{ clone_vol.nas.path | default('/' ~ clone_name) }} from SVM {{ dp_svm_name }} on a test client. + + # ================================================================ + # Phase E: Resync SnapMirror + validate healthy state + # ================================================================ + + - name: "Phase E — Resync SnapMirror relationship" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}?return_timeout=120" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: PATCH + body_format: json + body: + state: snapmirrored + register: resync_result + failed_when: false + no_log: false + + - name: "Phase E — Poll resync job" + ansible.builtin.uri: + url: "{{ dest_api }}/cluster/jobs/{{ resync_result.json.job.uuid }}" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: resync_job + until: resync_job.json.state in ['success', 'failure'] + retries: 30 + delay: 10 + when: >- + resync_result.json is defined + and resync_result.json.job is defined + failed_when: false + no_log: false + + - name: "Phase E — Log resync result" + ansible.builtin.debug: + msg: >- + Resync {{ 'triggered' if resync_result.status | default(0) in [200, 201, 202] + else 'skipped — ' ~ (resync_result.msg | default('unknown')) }} + + - name: "Phase E — Poll until relationship is snapmirrored" + ansible.builtin.uri: + url: "{{ dest_api }}/snapmirror/relationships/{{ rel_uuid }}?fields=state,lag_time,healthy" + url_username: "{{ dest_user }}" + url_password: "{{ dest_pass }}" + method: GET + register: sm_status + until: sm_status.json.state == 'snapmirrored' + retries: 120 + delay: 15 + no_log: false + + - name: "Phase E — Print completion summary" + ansible.builtin.debug: + msg: >- + === TEST FAILOVER COMPLETE — SnapMirror resynced === + | state={{ sm_status.json.state }} + | healthy={{ sm_status.json.healthy | default('unknown') }} + | lag={{ sm_status.json.lag_time | default('N/A') }}