From a8651eaff3866dc5b287ffba2505749b9ee312a5 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 29 Jul 2025 15:05:08 +0100 Subject: [PATCH] api: Separate volume, snapshot and volume attachments These all belong in separate files. Make it so. We also rename the volume_attachment schema file to volume_attachments, to better link it to the actual API code, and tweak an error message to fix some capitalization. Change-Id: Iffefc263bbf19d18137207c0432c16fdb3c513f9 Signed-off-by: Stephen Finucane --- api-ref/source/os-volume-attachments.inc | 22 +- api-ref/source/os-volumes.inc | 10 +- .../snapshot-create-req.json | 0 .../snapshot-create-resp.json | 0 .../snapshots-detail-resp.json | 0 .../snapshots-list-resp.json | 0 .../snapshots-show-resp.json | 0 .../attach-volume-to-server-req.json | 0 .../attach-volume-to-server-resp.json | 0 .../list-volume-attachments-resp.json | 0 .../update-volume-req.json | 0 .../v2.49/attach-volume-to-server-req.json | 0 .../v2.49/attach-volume-to-server-resp.json | 0 .../v2.49/list-volume-attachments-resp.json | 0 .../v2.49/update-volume-req.json | 0 .../v2.49/volume-attachment-detail-resp.json | 0 .../v2.70/attach-volume-to-server-req.json | 0 .../v2.70/attach-volume-to-server-resp.json | 0 .../v2.70/list-volume-attachments-resp.json | 0 .../v2.70/update-volume-req.json | 0 .../v2.70/volume-attachment-detail-resp.json | 0 .../v2.79/attach-volume-to-server-req.json | 0 .../v2.79/attach-volume-to-server-resp.json | 0 .../v2.79/list-volume-attachments-resp.json | 0 .../v2.79/update-volume-req.json | 0 .../v2.79/volume-attachment-detail-resp.json | 0 .../v2.85/attach-volume-to-server-req.json | 0 .../v2.85/attach-volume-to-server-resp.json | 0 .../v2.85/list-volume-attachments-resp.json | 0 ...ate-volume-attachment-delete-flag-req.json | 0 .../v2.85/update-volume-req.json | 0 .../v2.85/volume-attachment-detail-resp.json | 0 .../v2.89/attach-volume-to-server-req.json | 0 .../v2.89/attach-volume-to-server-resp.json | 0 .../v2.89/list-volume-attachments-resp.json | 0 ...ate-volume-attachment-delete-flag-req.json | 0 .../v2.89/volume-attachment-detail-resp.json | 0 .../volume-attachment-detail-resp.json | 0 nova/api/openstack/compute/routes.py | 6 +- .../openstack/compute/schemas/snapshots.py | 53 + .../compute/schemas/volume_attachment.py | 18 - .../compute/schemas/volume_attachments.py | 102 + nova/api/openstack/compute/schemas/volumes.py | 99 - nova/api/openstack/compute/snapshots.py | 151 ++ .../openstack/compute/volume_attachments.py | 402 ++++ nova/api/openstack/compute/volumes.py | 526 +---- .../snapshot-create-req.json.tpl | 0 .../snapshot-create-resp.json.tpl | 0 .../snapshots-detail-resp.json.tpl | 0 .../snapshots-list-resp.json.tpl | 0 .../snapshots-show-resp.json.tpl | 0 .../attach-volume-to-server-req.json.tpl | 0 .../attach-volume-to-server-resp.json.tpl | 0 .../list-volume-attachments-resp.json.tpl | 0 .../update-volume-req.json.tpl | 0 .../attach-volume-to-server-req.json.tpl | 0 .../attach-volume-to-server-resp.json.tpl | 0 .../list-volume-attachments-resp.json.tpl | 0 .../v2.49/update-volume-req.json.tpl | 0 .../volume-attachment-detail-resp.json.tpl | 0 .../attach-volume-to-server-req.json.tpl | 0 .../attach-volume-to-server-resp.json.tpl | 0 .../list-volume-attachments-resp.json.tpl | 0 .../v2.70/update-volume-req.json.tpl | 0 .../volume-attachment-detail-resp.json.tpl | 0 .../attach-volume-to-server-req.json.tpl | 0 .../attach-volume-to-server-resp.json.tpl | 0 .../list-volume-attachments-resp.json.tpl | 0 .../v2.79/update-volume-req.json.tpl | 0 .../volume-attachment-detail-resp.json.tpl | 0 .../attach-volume-to-server-req.json.tpl | 0 .../attach-volume-to-server-resp.json.tpl | 0 .../list-volume-attachments-resp.json.tpl | 0 ...volume-attachment-delete-flag-req.json.tpl | 0 .../v2.85/update-volume-req.json.tpl | 0 .../volume-attachment-detail-resp.json.tpl | 0 .../attach-volume-to-server-req.json.tpl | 0 .../attach-volume-to-server-resp.json.tpl | 0 .../list-volume-attachments-resp.json.tpl | 0 ...volume-attachment-delete-flag-req.json.tpl | 0 .../v2.89/update-volume-req.json.tpl | 0 .../volume-attachment-detail-resp.json.tpl | 0 .../volume-attachment-detail-resp.json.tpl | 0 .../api_sample_tests/test_snapshots.py | 75 + .../test_volume_attachments.py | 161 ++ .../api_sample_tests/test_volumes.py | 199 +- .../regressions/test_bug_1943431.py | 2 +- .../regressions/test_bug_2112187.py | 2 +- .../compute/test_assisted_snapshots.py | 255 +++ .../api/openstack/compute/test_snapshots.py | 32 +- .../compute/test_volume_attachments.py | 1396 +++++++++++++ .../api/openstack/compute/test_volumes.py | 1848 +---------------- nova/tests/unit/policies/test_snapshots.py | 242 +++ .../unit/policies/test_volume_attachments.py | 276 +++ nova/tests/unit/policies/test_volumes.py | 306 +-- 95 files changed, 3276 insertions(+), 2907 deletions(-) rename doc/api_samples/{os-volumes => os-snapshots}/snapshot-create-req.json (100%) rename doc/api_samples/{os-volumes => os-snapshots}/snapshot-create-resp.json (100%) rename doc/api_samples/{os-volumes => os-snapshots}/snapshots-detail-resp.json (100%) rename doc/api_samples/{os-volumes => os-snapshots}/snapshots-list-resp.json (100%) rename doc/api_samples/{os-volumes => os-snapshots}/snapshots-show-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/attach-volume-to-server-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/attach-volume-to-server-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/list-volume-attachments-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/update-volume-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.49/attach-volume-to-server-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.49/attach-volume-to-server-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.49/list-volume-attachments-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.49/update-volume-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.49/volume-attachment-detail-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.70/attach-volume-to-server-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.70/attach-volume-to-server-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.70/list-volume-attachments-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.70/update-volume-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.70/volume-attachment-detail-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.79/attach-volume-to-server-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.79/attach-volume-to-server-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.79/list-volume-attachments-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.79/update-volume-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.79/volume-attachment-detail-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.85/attach-volume-to-server-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.85/attach-volume-to-server-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.85/list-volume-attachments-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.85/update-volume-attachment-delete-flag-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.85/update-volume-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.85/volume-attachment-detail-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.89/attach-volume-to-server-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.89/attach-volume-to-server-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.89/list-volume-attachments-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.89/update-volume-attachment-delete-flag-req.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/v2.89/volume-attachment-detail-resp.json (100%) rename doc/api_samples/{os-volumes => os-volume_attachments}/volume-attachment-detail-resp.json (100%) create mode 100644 nova/api/openstack/compute/schemas/snapshots.py delete mode 100644 nova/api/openstack/compute/schemas/volume_attachment.py create mode 100644 nova/api/openstack/compute/schemas/volume_attachments.py create mode 100644 nova/api/openstack/compute/snapshots.py create mode 100644 nova/api/openstack/compute/volume_attachments.py rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-snapshots}/snapshot-create-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-snapshots}/snapshot-create-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-snapshots}/snapshots-detail-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-snapshots}/snapshots-list-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-snapshots}/snapshots-show-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/attach-volume-to-server-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/attach-volume-to-server-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/list-volume-attachments-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/update-volume-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.49/attach-volume-to-server-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.49/attach-volume-to-server-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.49/list-volume-attachments-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.49/update-volume-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.49/volume-attachment-detail-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.70/attach-volume-to-server-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.70/attach-volume-to-server-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.70/list-volume-attachments-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.70/update-volume-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.70/volume-attachment-detail-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.79/attach-volume-to-server-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.79/attach-volume-to-server-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.79/list-volume-attachments-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.79/update-volume-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.79/volume-attachment-detail-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.85/attach-volume-to-server-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.85/attach-volume-to-server-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.85/list-volume-attachments-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.85/update-volume-attachment-delete-flag-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.85/update-volume-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.85/volume-attachment-detail-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.89/attach-volume-to-server-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.89/attach-volume-to-server-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.89/list-volume-attachments-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.89/update-volume-attachment-delete-flag-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.89/update-volume-req.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/v2.89/volume-attachment-detail-resp.json.tpl (100%) rename nova/tests/functional/api_sample_tests/api_samples/{os-volumes => os-volume_attachments}/volume-attachment-detail-resp.json.tpl (100%) create mode 100644 nova/tests/functional/api_sample_tests/test_snapshots.py create mode 100644 nova/tests/functional/api_sample_tests/test_volume_attachments.py create mode 100644 nova/tests/unit/api/openstack/compute/test_assisted_snapshots.py create mode 100644 nova/tests/unit/api/openstack/compute/test_volume_attachments.py create mode 100644 nova/tests/unit/policies/test_snapshots.py create mode 100644 nova/tests/unit/policies/test_volume_attachments.py diff --git a/api-ref/source/os-volume-attachments.inc b/api-ref/source/os-volume-attachments.inc index bf5d627455ef..c20e8121f49b 100644 --- a/api-ref/source/os-volume-attachments.inc +++ b/api-ref/source/os-volume-attachments.inc @@ -45,12 +45,12 @@ Response **Example List volume attachments for an instance: JSON response** -.. literalinclude:: ../../doc/api_samples/os-volumes/list-volume-attachments-resp.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/list-volume-attachments-resp.json :language: javascript **Example List tagged volume attachments for an instance (v2.89): JSON response** -.. literalinclude:: ../../doc/api_samples/os-volumes/v2.89/list-volume-attachments-resp.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/v2.89/list-volume-attachments-resp.json :language: javascript Attach a volume to an instance @@ -90,17 +90,17 @@ Request **Example Attach a volume to an instance: JSON request** -.. literalinclude:: ../../doc/api_samples/os-volumes/attach-volume-to-server-req.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/attach-volume-to-server-req.json :language: javascript **Example Attach a volume to an instance and tag it (v2.49): JSON request** -.. literalinclude:: ../../doc/api_samples/os-volumes/v2.49/attach-volume-to-server-req.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-req.json :language: javascript **Example Attach a volume to an instance with "delete_on_termination" (v2.79): JSON request** -.. literalinclude:: ../../doc/api_samples/os-volumes/v2.79/attach-volume-to-server-req.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-req.json :language: javascript Response @@ -118,17 +118,17 @@ Response **Example Attach a volume to an instance: JSON response** -.. literalinclude:: ../../doc/api_samples/os-volumes/attach-volume-to-server-resp.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/attach-volume-to-server-resp.json :language: javascript **Example Attach a tagged volume to an instance (v2.70): JSON response** -.. literalinclude:: ../../doc/api_samples/os-volumes/v2.70/attach-volume-to-server-resp.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-resp.json :language: javascript **Example Attach a volume with "delete_on_termination" (v2.79): JSON response** -.. literalinclude:: ../../doc/api_samples/os-volumes/v2.79/attach-volume-to-server-resp.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-resp.json :language: javascript Show a detail of a volume attachment @@ -167,12 +167,12 @@ Response **Example Show a detail of a volume attachment: JSON response** -.. literalinclude:: ../../doc/api_samples/os-volumes/volume-attachment-detail-resp.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/volume-attachment-detail-resp.json :language: javascript **Example Show a detail of a tagged volume attachment (v2.89): JSON response** -.. literalinclude:: ../../doc/api_samples/os-volumes/v2.89/volume-attachment-detail-resp.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/v2.89/volume-attachment-detail-resp.json :language: javascript Update a volume attachment @@ -233,7 +233,7 @@ Request **Example Update a volume attachment (v2.85): JSON request** -.. literalinclude:: ../../doc/api_samples/os-volumes/v2.85/update-volume-attachment-delete-flag-req.json +.. literalinclude:: ../../doc/api_samples/os-volume_attachments/v2.85/update-volume-attachment-delete-flag-req.json :language: javascript Response diff --git a/api-ref/source/os-volumes.inc b/api-ref/source/os-volumes.inc index 1f7119789002..945cc02cf4cf 100644 --- a/api-ref/source/os-volumes.inc +++ b/api-ref/source/os-volumes.inc @@ -279,7 +279,7 @@ Response **Example List Snapshots** -.. literalinclude:: ../../doc/api_samples/os-volumes/snapshots-list-resp.json +.. literalinclude:: ../../doc/api_samples/os-snapshots/snapshots-list-resp.json :language: javascript Create Snapshot @@ -306,7 +306,7 @@ Request **Example Create Snapshot** -.. literalinclude:: ../../doc/api_samples/os-volumes/snapshot-create-req.json +.. literalinclude:: ../../doc/api_samples/os-snapshots/snapshot-create-req.json :language: javascript Response @@ -325,7 +325,7 @@ Response **Example Create Snapshot** -.. literalinclude:: ../../doc/api_samples/os-volumes/snapshot-create-resp.json +.. literalinclude:: ../../doc/api_samples/os-snapshots/snapshot-create-resp.json :language: javascript List Snapshots With Details @@ -365,7 +365,7 @@ Response **Example List Snapshots With Details** -.. literalinclude:: ../../doc/api_samples/os-volumes/snapshots-detail-resp.json +.. literalinclude:: ../../doc/api_samples/os-snapshots/snapshots-detail-resp.json :language: javascript Show Snapshot Details @@ -404,7 +404,7 @@ Response **Example Show Snapshot Details** -.. literalinclude:: ../../doc/api_samples/os-volumes/snapshots-show-resp.json +.. literalinclude:: ../../doc/api_samples/os-snapshots/snapshots-show-resp.json :language: javascript Delete Snapshot diff --git a/doc/api_samples/os-volumes/snapshot-create-req.json b/doc/api_samples/os-snapshots/snapshot-create-req.json similarity index 100% rename from doc/api_samples/os-volumes/snapshot-create-req.json rename to doc/api_samples/os-snapshots/snapshot-create-req.json diff --git a/doc/api_samples/os-volumes/snapshot-create-resp.json b/doc/api_samples/os-snapshots/snapshot-create-resp.json similarity index 100% rename from doc/api_samples/os-volumes/snapshot-create-resp.json rename to doc/api_samples/os-snapshots/snapshot-create-resp.json diff --git a/doc/api_samples/os-volumes/snapshots-detail-resp.json b/doc/api_samples/os-snapshots/snapshots-detail-resp.json similarity index 100% rename from doc/api_samples/os-volumes/snapshots-detail-resp.json rename to doc/api_samples/os-snapshots/snapshots-detail-resp.json diff --git a/doc/api_samples/os-volumes/snapshots-list-resp.json b/doc/api_samples/os-snapshots/snapshots-list-resp.json similarity index 100% rename from doc/api_samples/os-volumes/snapshots-list-resp.json rename to doc/api_samples/os-snapshots/snapshots-list-resp.json diff --git a/doc/api_samples/os-volumes/snapshots-show-resp.json b/doc/api_samples/os-snapshots/snapshots-show-resp.json similarity index 100% rename from doc/api_samples/os-volumes/snapshots-show-resp.json rename to doc/api_samples/os-snapshots/snapshots-show-resp.json diff --git a/doc/api_samples/os-volumes/attach-volume-to-server-req.json b/doc/api_samples/os-volume_attachments/attach-volume-to-server-req.json similarity index 100% rename from doc/api_samples/os-volumes/attach-volume-to-server-req.json rename to doc/api_samples/os-volume_attachments/attach-volume-to-server-req.json diff --git a/doc/api_samples/os-volumes/attach-volume-to-server-resp.json b/doc/api_samples/os-volume_attachments/attach-volume-to-server-resp.json similarity index 100% rename from doc/api_samples/os-volumes/attach-volume-to-server-resp.json rename to doc/api_samples/os-volume_attachments/attach-volume-to-server-resp.json diff --git a/doc/api_samples/os-volumes/list-volume-attachments-resp.json b/doc/api_samples/os-volume_attachments/list-volume-attachments-resp.json similarity index 100% rename from doc/api_samples/os-volumes/list-volume-attachments-resp.json rename to doc/api_samples/os-volume_attachments/list-volume-attachments-resp.json diff --git a/doc/api_samples/os-volumes/update-volume-req.json b/doc/api_samples/os-volume_attachments/update-volume-req.json similarity index 100% rename from doc/api_samples/os-volumes/update-volume-req.json rename to doc/api_samples/os-volume_attachments/update-volume-req.json diff --git a/doc/api_samples/os-volumes/v2.49/attach-volume-to-server-req.json b/doc/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.49/attach-volume-to-server-req.json rename to doc/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-req.json diff --git a/doc/api_samples/os-volumes/v2.49/attach-volume-to-server-resp.json b/doc/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.49/attach-volume-to-server-resp.json rename to doc/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-resp.json diff --git a/doc/api_samples/os-volumes/v2.49/list-volume-attachments-resp.json b/doc/api_samples/os-volume_attachments/v2.49/list-volume-attachments-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.49/list-volume-attachments-resp.json rename to doc/api_samples/os-volume_attachments/v2.49/list-volume-attachments-resp.json diff --git a/doc/api_samples/os-volumes/v2.49/update-volume-req.json b/doc/api_samples/os-volume_attachments/v2.49/update-volume-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.49/update-volume-req.json rename to doc/api_samples/os-volume_attachments/v2.49/update-volume-req.json diff --git a/doc/api_samples/os-volumes/v2.49/volume-attachment-detail-resp.json b/doc/api_samples/os-volume_attachments/v2.49/volume-attachment-detail-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.49/volume-attachment-detail-resp.json rename to doc/api_samples/os-volume_attachments/v2.49/volume-attachment-detail-resp.json diff --git a/doc/api_samples/os-volumes/v2.70/attach-volume-to-server-req.json b/doc/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.70/attach-volume-to-server-req.json rename to doc/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-req.json diff --git a/doc/api_samples/os-volumes/v2.70/attach-volume-to-server-resp.json b/doc/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.70/attach-volume-to-server-resp.json rename to doc/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-resp.json diff --git a/doc/api_samples/os-volumes/v2.70/list-volume-attachments-resp.json b/doc/api_samples/os-volume_attachments/v2.70/list-volume-attachments-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.70/list-volume-attachments-resp.json rename to doc/api_samples/os-volume_attachments/v2.70/list-volume-attachments-resp.json diff --git a/doc/api_samples/os-volumes/v2.70/update-volume-req.json b/doc/api_samples/os-volume_attachments/v2.70/update-volume-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.70/update-volume-req.json rename to doc/api_samples/os-volume_attachments/v2.70/update-volume-req.json diff --git a/doc/api_samples/os-volumes/v2.70/volume-attachment-detail-resp.json b/doc/api_samples/os-volume_attachments/v2.70/volume-attachment-detail-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.70/volume-attachment-detail-resp.json rename to doc/api_samples/os-volume_attachments/v2.70/volume-attachment-detail-resp.json diff --git a/doc/api_samples/os-volumes/v2.79/attach-volume-to-server-req.json b/doc/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.79/attach-volume-to-server-req.json rename to doc/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-req.json diff --git a/doc/api_samples/os-volumes/v2.79/attach-volume-to-server-resp.json b/doc/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.79/attach-volume-to-server-resp.json rename to doc/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-resp.json diff --git a/doc/api_samples/os-volumes/v2.79/list-volume-attachments-resp.json b/doc/api_samples/os-volume_attachments/v2.79/list-volume-attachments-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.79/list-volume-attachments-resp.json rename to doc/api_samples/os-volume_attachments/v2.79/list-volume-attachments-resp.json diff --git a/doc/api_samples/os-volumes/v2.79/update-volume-req.json b/doc/api_samples/os-volume_attachments/v2.79/update-volume-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.79/update-volume-req.json rename to doc/api_samples/os-volume_attachments/v2.79/update-volume-req.json diff --git a/doc/api_samples/os-volumes/v2.79/volume-attachment-detail-resp.json b/doc/api_samples/os-volume_attachments/v2.79/volume-attachment-detail-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.79/volume-attachment-detail-resp.json rename to doc/api_samples/os-volume_attachments/v2.79/volume-attachment-detail-resp.json diff --git a/doc/api_samples/os-volumes/v2.85/attach-volume-to-server-req.json b/doc/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.85/attach-volume-to-server-req.json rename to doc/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-req.json diff --git a/doc/api_samples/os-volumes/v2.85/attach-volume-to-server-resp.json b/doc/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.85/attach-volume-to-server-resp.json rename to doc/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-resp.json diff --git a/doc/api_samples/os-volumes/v2.85/list-volume-attachments-resp.json b/doc/api_samples/os-volume_attachments/v2.85/list-volume-attachments-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.85/list-volume-attachments-resp.json rename to doc/api_samples/os-volume_attachments/v2.85/list-volume-attachments-resp.json diff --git a/doc/api_samples/os-volumes/v2.85/update-volume-attachment-delete-flag-req.json b/doc/api_samples/os-volume_attachments/v2.85/update-volume-attachment-delete-flag-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.85/update-volume-attachment-delete-flag-req.json rename to doc/api_samples/os-volume_attachments/v2.85/update-volume-attachment-delete-flag-req.json diff --git a/doc/api_samples/os-volumes/v2.85/update-volume-req.json b/doc/api_samples/os-volume_attachments/v2.85/update-volume-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.85/update-volume-req.json rename to doc/api_samples/os-volume_attachments/v2.85/update-volume-req.json diff --git a/doc/api_samples/os-volumes/v2.85/volume-attachment-detail-resp.json b/doc/api_samples/os-volume_attachments/v2.85/volume-attachment-detail-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.85/volume-attachment-detail-resp.json rename to doc/api_samples/os-volume_attachments/v2.85/volume-attachment-detail-resp.json diff --git a/doc/api_samples/os-volumes/v2.89/attach-volume-to-server-req.json b/doc/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.89/attach-volume-to-server-req.json rename to doc/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-req.json diff --git a/doc/api_samples/os-volumes/v2.89/attach-volume-to-server-resp.json b/doc/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.89/attach-volume-to-server-resp.json rename to doc/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-resp.json diff --git a/doc/api_samples/os-volumes/v2.89/list-volume-attachments-resp.json b/doc/api_samples/os-volume_attachments/v2.89/list-volume-attachments-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.89/list-volume-attachments-resp.json rename to doc/api_samples/os-volume_attachments/v2.89/list-volume-attachments-resp.json diff --git a/doc/api_samples/os-volumes/v2.89/update-volume-attachment-delete-flag-req.json b/doc/api_samples/os-volume_attachments/v2.89/update-volume-attachment-delete-flag-req.json similarity index 100% rename from doc/api_samples/os-volumes/v2.89/update-volume-attachment-delete-flag-req.json rename to doc/api_samples/os-volume_attachments/v2.89/update-volume-attachment-delete-flag-req.json diff --git a/doc/api_samples/os-volumes/v2.89/volume-attachment-detail-resp.json b/doc/api_samples/os-volume_attachments/v2.89/volume-attachment-detail-resp.json similarity index 100% rename from doc/api_samples/os-volumes/v2.89/volume-attachment-detail-resp.json rename to doc/api_samples/os-volume_attachments/v2.89/volume-attachment-detail-resp.json diff --git a/doc/api_samples/os-volumes/volume-attachment-detail-resp.json b/doc/api_samples/os-volume_attachments/volume-attachment-detail-resp.json similarity index 100% rename from doc/api_samples/os-volumes/volume-attachment-detail-resp.json rename to doc/api_samples/os-volume_attachments/volume-attachment-detail-resp.json diff --git a/nova/api/openstack/compute/routes.py b/nova/api/openstack/compute/routes.py index 293e32fbdef2..5c7da53c8d8d 100644 --- a/nova/api/openstack/compute/routes.py +++ b/nova/api/openstack/compute/routes.py @@ -79,10 +79,12 @@ from nova.api.openstack.compute import servers from nova.api.openstack.compute import services from nova.api.openstack.compute import shelve from nova.api.openstack.compute import simple_tenant_usage +from nova.api.openstack.compute import snapshots from nova.api.openstack.compute import suspend_server from nova.api.openstack.compute import tenant_networks from nova.api.openstack.compute import versionsV21 from nova.api.openstack.compute import virtual_interfaces +from nova.api.openstack.compute import volume_attachments from nova.api.openstack.compute import volumes from nova.api.openstack import wsgi from nova.api import wsgi as base_wsgi @@ -321,7 +323,7 @@ server_topology_controller = functools.partial(_create_controller, server_topology.ServerTopologyController, []) server_volume_attachments_controller = functools.partial(_create_controller, - volumes.VolumeAttachmentController, []) + volume_attachments.VolumeAttachmentController, []) services_controller = functools.partial(_create_controller, @@ -333,7 +335,7 @@ simple_tenant_usage_controller = functools.partial(_create_controller, snapshots_controller = functools.partial(_create_controller, - volumes.SnapshotController, []) + snapshots.SnapshotController, []) tenant_networks_controller = functools.partial(_create_controller, diff --git a/nova/api/openstack/compute/schemas/snapshots.py b/nova/api/openstack/compute/schemas/snapshots.py new file mode 100644 index 000000000000..964db98c55c6 --- /dev/null +++ b/nova/api/openstack/compute/schemas/snapshots.py @@ -0,0 +1,53 @@ +# Copyright 2014 IBM Corporation. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.api.validation import parameter_types + +create = { + 'type': 'object', + 'properties': { + 'snapshot': { + 'type': 'object', + 'properties': { + 'volume_id': {'type': 'string'}, + 'force': parameter_types.boolean, + 'display_name': {'type': 'string'}, + 'display_description': {'type': 'string'}, + }, + 'required': ['volume_id'], + 'additionalProperties': False, + }, + }, + 'required': ['snapshot'], + 'additionalProperties': False, +} + +index_query = { + 'type': 'object', + 'properties': { + 'limit': parameter_types.multi_params( + parameter_types.non_negative_integer), + 'offset': parameter_types.multi_params( + parameter_types.non_negative_integer) + }, + 'additionalProperties': True +} + +detail_query = index_query + +show_query = { + 'type': 'object', + 'properties': {}, + 'additionalProperties': True +} diff --git a/nova/api/openstack/compute/schemas/volume_attachment.py b/nova/api/openstack/compute/schemas/volume_attachment.py deleted file mode 100644 index 1e257739faaa..000000000000 --- a/nova/api/openstack/compute/schemas/volume_attachment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -# TODO(stephenfin): Remove additionalProperties in a future API version -show_query = { - 'type': 'object', - 'properties': {}, - 'additionalProperties': True, -} diff --git a/nova/api/openstack/compute/schemas/volume_attachments.py b/nova/api/openstack/compute/schemas/volume_attachments.py new file mode 100644 index 000000000000..faa17aa358a6 --- /dev/null +++ b/nova/api/openstack/compute/schemas/volume_attachments.py @@ -0,0 +1,102 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy + +from nova.api.validation import parameter_types + +create = { + 'type': 'object', + 'properties': { + 'volumeAttachment': { + 'type': 'object', + 'properties': { + 'volumeId': parameter_types.volume_id, + 'device': { + 'type': ['string', 'null'], + # NOTE: The validation pattern from match_device() in + # nova/block_device.py. + 'pattern': '(^/dev/x{0,1}[a-z]{0,1}d{0,1})([a-z]+)[0-9]*$' + }, + }, + 'required': ['volumeId'], + 'additionalProperties': False, + }, + }, + 'required': ['volumeAttachment'], + 'additionalProperties': False, +} +create_v249 = copy.deepcopy(create) +create_v249['properties']['volumeAttachment'][ + 'properties']['tag'] = parameter_types.tag + +create_v279 = copy.deepcopy(create_v249) +create_v279['properties']['volumeAttachment'][ + 'properties']['delete_on_termination'] = parameter_types.boolean + +update = copy.deepcopy(create) +del update['properties']['volumeAttachment']['properties']['device'] + +# NOTE(brinzhang): Allow attachment_id, serverId, device, tag, and +# delete_on_termination (i.e., follow the content of the GET response) +# to be specified for RESTfulness, even though we will not allow updating +# all of them. +update_v285 = { + 'type': 'object', + 'properties': { + 'volumeAttachment': { + 'type': 'object', + 'properties': { + 'volumeId': parameter_types.volume_id, + 'device': { + 'type': ['string', 'null'], + # NOTE: The validation pattern from match_device() in + # nova/block_device.py. + 'pattern': '(^/dev/x{0,1}[a-z]{0,1}d{0,1})([a-z]+)[0-9]*$' + }, + 'tag': parameter_types.tag, + 'delete_on_termination': parameter_types.boolean, + 'serverId': parameter_types.server_id, + 'id': parameter_types.attachment_id + }, + 'required': ['volumeId'], + 'additionalProperties': False, + }, + }, + 'required': ['volumeAttachment'], + 'additionalProperties': False, +} + +index_query = { + 'type': 'object', + 'properties': { + 'limit': parameter_types.multi_params( + parameter_types.non_negative_integer), + 'offset': parameter_types.multi_params( + parameter_types.non_negative_integer) + }, + # NOTE(gmann): This is kept True to keep backward compatibility. + # As of now Schema validation stripped out the additional parameters and + # does not raise 400. In microversion 2.75, we have blocked the additional + # parameters. + 'additionalProperties': True +} + +index_query_v275 = copy.deepcopy(index_query) +index_query_v275['additionalProperties'] = False + +# TODO(stephenfin): Remove additionalProperties in a future API version +show_query = { + 'type': 'object', + 'properties': {}, + 'additionalProperties': True, +} diff --git a/nova/api/openstack/compute/schemas/volumes.py b/nova/api/openstack/compute/schemas/volumes.py index d2b6e7e4a358..b14417aa2b34 100644 --- a/nova/api/openstack/compute/schemas/volumes.py +++ b/nova/api/openstack/compute/schemas/volumes.py @@ -12,8 +12,6 @@ # License for the specific language governing permissions and limitations # under the License. -import copy - from nova.api.validation import parameter_types create = { @@ -43,88 +41,6 @@ create = { } -snapshot_create = { - 'type': 'object', - 'properties': { - 'snapshot': { - 'type': 'object', - 'properties': { - 'volume_id': {'type': 'string'}, - 'force': parameter_types.boolean, - 'display_name': {'type': 'string'}, - 'display_description': {'type': 'string'}, - }, - 'required': ['volume_id'], - 'additionalProperties': False, - }, - }, - 'required': ['snapshot'], - 'additionalProperties': False, -} - -create_volume_attachment = { - 'type': 'object', - 'properties': { - 'volumeAttachment': { - 'type': 'object', - 'properties': { - 'volumeId': parameter_types.volume_id, - 'device': { - 'type': ['string', 'null'], - # NOTE: The validation pattern from match_device() in - # nova/block_device.py. - 'pattern': '(^/dev/x{0,1}[a-z]{0,1}d{0,1})([a-z]+)[0-9]*$' - }, - }, - 'required': ['volumeId'], - 'additionalProperties': False, - }, - }, - 'required': ['volumeAttachment'], - 'additionalProperties': False, -} -create_volume_attachment_v249 = copy.deepcopy(create_volume_attachment) -create_volume_attachment_v249['properties']['volumeAttachment'][ - 'properties']['tag'] = parameter_types.tag - -create_volume_attachment_v279 = copy.deepcopy(create_volume_attachment_v249) -create_volume_attachment_v279['properties']['volumeAttachment'][ - 'properties']['delete_on_termination'] = parameter_types.boolean - -update_volume_attachment = copy.deepcopy(create_volume_attachment) -del update_volume_attachment['properties']['volumeAttachment'][ - 'properties']['device'] - -# NOTE(brinzhang): Allow attachment_id, serverId, device, tag, and -# delete_on_termination (i.e., follow the content of the GET response) -# to be specified for RESTfulness, even though we will not allow updating -# all of them. -update_volume_attachment_v285 = { - 'type': 'object', - 'properties': { - 'volumeAttachment': { - 'type': 'object', - 'properties': { - 'volumeId': parameter_types.volume_id, - 'device': { - 'type': ['string', 'null'], - # NOTE: The validation pattern from match_device() in - # nova/block_device.py. - 'pattern': '(^/dev/x{0,1}[a-z]{0,1}d{0,1})([a-z]+)[0-9]*$' - }, - 'tag': parameter_types.tag, - 'delete_on_termination': parameter_types.boolean, - 'serverId': parameter_types.server_id, - 'id': parameter_types.attachment_id - }, - 'required': ['volumeId'], - 'additionalProperties': False, - }, - }, - 'required': ['volumeAttachment'], - 'additionalProperties': False, -} - index_query = { 'type': 'object', 'properties': { @@ -133,28 +49,13 @@ index_query = { 'offset': parameter_types.multi_params( parameter_types.non_negative_integer) }, - # NOTE(gmann): This is kept True to keep backward compatibility. - # As of now Schema validation stripped out the additional parameters and - # does not raise 400. In microversion 2.75, we have blocked the additional - # parameters. 'additionalProperties': True } detail_query = index_query -index_query_275 = copy.deepcopy(index_query) -index_query_275['additionalProperties'] = False - -# TODO(stephenfin): Remove additionalProperties in a future API version show_query = { 'type': 'object', 'properties': {}, 'additionalProperties': True } - -# TODO(stephenfin): Remove additionalProperties in a future API version -snapshot_show_query = { - 'type': 'object', - 'properties': {}, - 'additionalProperties': True -} diff --git a/nova/api/openstack/compute/snapshots.py b/nova/api/openstack/compute/snapshots.py new file mode 100644 index 000000000000..8515fbf5cfe2 --- /dev/null +++ b/nova/api/openstack/compute/snapshots.py @@ -0,0 +1,151 @@ +# Copyright 2011 Justin Santa Barbara +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""The volume snapshots extension.""" + +from oslo_utils import strutils +from webob import exc + +from nova.api.openstack.api_version_request \ + import MAX_PROXY_API_SUPPORT_VERSION +from nova.api.openstack import common +from nova.api.openstack.compute.schemas import snapshots as schema +from nova.api.openstack import wsgi +from nova.api import validation +from nova import exception +from nova.policies import volumes as vol_policies +from nova.volume import cinder + + +def _translate_snapshot_detail_view(context, vol): + """Maps keys for snapshots details view.""" + return _translate_snapshot_summary_view(context, vol) + + +def _translate_snapshot_summary_view(context, vol): + """Maps keys for snapshots summary view.""" + d = {} + + d['id'] = vol['id'] + d['volumeId'] = vol['volume_id'] + d['status'] = vol['status'] + # NOTE(gagupta): We map volume_size as the snapshot size + d['size'] = vol['volume_size'] + d['createdAt'] = vol['created_at'] + d['displayName'] = vol['display_name'] + d['displayDescription'] = vol['display_description'] + return d + + +class SnapshotController(wsgi.Controller): + """The Snapshots API controller for the OpenStack API.""" + + def __init__(self): + super().__init__() + self.volume_api = cinder.API() + + @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) + @wsgi.expected_errors(404) + @validation.query_schema(schema.show_query) + def show(self, req, id): + """Return data about the given snapshot.""" + context = req.environ['nova.context'] + context.can( + vol_policies.POLICY_NAME % 'snapshots:show', + target={'project_id': context.project_id}) + + try: + vol = self.volume_api.get_snapshot(context, id) + except exception.SnapshotNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + return {'snapshot': _translate_snapshot_detail_view(context, vol)} + + @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) + @wsgi.response(202) + @wsgi.expected_errors(404) + def delete(self, req, id): + """Delete a snapshot.""" + context = req.environ['nova.context'] + context.can( + vol_policies.POLICY_NAME % 'snapshots:delete', + target={'project_id': context.project_id}) + + try: + self.volume_api.delete_snapshot(context, id) + except exception.SnapshotNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) + @wsgi.expected_errors(()) + @validation.query_schema(schema.index_query) + def index(self, req): + """Returns a summary list of snapshots.""" + context = req.environ['nova.context'] + context.can( + vol_policies.POLICY_NAME % 'snapshots:list', + target={'project_id': context.project_id}) + return self._items(req, entity_maker=_translate_snapshot_summary_view) + + @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) + @wsgi.expected_errors(()) + @validation.query_schema(schema.detail_query) + def detail(self, req): + """Returns a detailed list of snapshots.""" + context = req.environ['nova.context'] + context.can( + vol_policies.POLICY_NAME % 'snapshots:detail', + target={'project_id': context.project_id}) + return self._items(req, entity_maker=_translate_snapshot_detail_view) + + def _items(self, req, entity_maker): + """Returns a list of snapshots, transformed through entity_maker.""" + context = req.environ['nova.context'] + + snapshots = self.volume_api.get_all_snapshots(context) + limited_list = common.limited(snapshots, req) + res = [entity_maker(context, snapshot) for snapshot in limited_list] + return {'snapshots': res} + + @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) + @wsgi.expected_errors((400, 403)) + @validation.schema(schema.create) + def create(self, req, body): + """Creates a new snapshot.""" + context = req.environ['nova.context'] + context.can( + vol_policies.POLICY_NAME % 'snapshots:create', + target={'project_id': context.project_id}) + + snapshot = body['snapshot'] + volume_id = snapshot['volume_id'] + + force = snapshot.get('force', False) + force = strutils.bool_from_string(force, strict=True) + if force: + create_func = self.volume_api.create_snapshot_force + else: + create_func = self.volume_api.create_snapshot + + try: + new_snapshot = create_func( + context, volume_id, + snapshot.get('display_name'), + snapshot.get('display_description')) + except exception.OverQuota as e: + raise exc.HTTPForbidden(explanation=e.format_message()) + + retval = _translate_snapshot_detail_view(context, new_snapshot) + return {'snapshot': retval} diff --git a/nova/api/openstack/compute/volume_attachments.py b/nova/api/openstack/compute/volume_attachments.py new file mode 100644 index 000000000000..212ecc052b62 --- /dev/null +++ b/nova/api/openstack/compute/volume_attachments.py @@ -0,0 +1,402 @@ +# Copyright 2011 Justin Santa Barbara +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""The volume attachments extension.""" + +from oslo_utils import strutils +from webob import exc + +from nova.api.openstack import api_version_request +from nova.api.openstack import common +from nova.api.openstack.compute.schemas import volume_attachments as schema +from nova.api.openstack import wsgi +from nova.api import validation +from nova.compute import api as compute +from nova.compute import vm_states +from nova import exception +from nova.i18n import _ +from nova import objects +from nova.policies import volumes_attachments as va_policies +from nova.volume import cinder + + +def _translate_attachment_detail_view( + bdm, + show_tag=False, + show_delete_on_termination=False, + show_attachment_id_bdm_uuid=False, +): + """Maps keys for attachment details view. + + :param bdm: BlockDeviceMapping object for an attached volume + :param show_tag: True if the "tag" field should be in the response, False + to exclude the "tag" field from the response + :param show_delete_on_termination: True if the "delete_on_termination" + field should be in the response, False to exclude the + "delete_on_termination" field from the response + :param show_attachment_id_bdm_uuid: True if the "attachment_id" and + "bdm_uuid" fields should be in the response. Also controls when the + "id" field is included. + """ + + d = {} + + if not show_attachment_id_bdm_uuid: + d['id'] = bdm.volume_id + + d['volumeId'] = bdm.volume_id + d['serverId'] = bdm.instance_uuid + + if bdm.device_name: + d['device'] = bdm.device_name + + if show_tag: + d['tag'] = bdm.tag + + if show_delete_on_termination: + d['delete_on_termination'] = bdm.delete_on_termination + + if show_attachment_id_bdm_uuid: + d['attachment_id'] = bdm.attachment_id + d['bdm_uuid'] = bdm.uuid + + return d + + +def _check_request_version(req, min_version, method, server_id, server_state): + if api_version_request.is_supported(req, min_version): + return + + exc_inv = exception.InstanceInvalidState( + attr='vm_state', + instance_uuid=server_id, + state=server_state, + method=method) + common.raise_http_conflict_for_instance_invalid_state( + exc_inv, method, server_id) + + +class VolumeAttachmentController(wsgi.Controller): + """The volume attachment API controller for the OpenStack API. + + A child resource of the server. Note that we use the volume id + as the ID of the attachment (though this is not guaranteed externally) + + """ + + def __init__(self): + super().__init__() + self.compute_api = compute.API() + self.volume_api = cinder.API() + + @wsgi.expected_errors(404) + @validation.query_schema(schema.index_query, '2.0', '2.74') + @validation.query_schema(schema.index_query_v275, '2.75') + def index(self, req, server_id): + """Returns the list of volume attachments for a given instance.""" + context = req.environ['nova.context'] + instance = common.get_instance(self.compute_api, context, server_id) + context.can( + va_policies.POLICY_ROOT % 'index', + target={'project_id': instance.project_id}) + + bdms = objects.BlockDeviceMappingList.get_by_instance_uuid( + context, instance.uuid) + limited_list = common.limited(bdms, req) + + results = [] + show_tag = api_version_request.is_supported(req, '2.70') + show_delete_on_termination = api_version_request.is_supported( + req, '2.79') + show_attachment_id_bdm_uuid = api_version_request.is_supported( + req, '2.89') + for bdm in limited_list: + if bdm.volume_id: + va = _translate_attachment_detail_view( + bdm, + show_tag=show_tag, + show_delete_on_termination=show_delete_on_termination, + show_attachment_id_bdm_uuid=show_attachment_id_bdm_uuid, + ) + results.append(va) + + return {'volumeAttachments': results} + + @wsgi.expected_errors(404) + @validation.query_schema(schema.show_query) + def show(self, req, server_id, id): + """Return data about the given volume attachment.""" + context = req.environ['nova.context'] + instance = common.get_instance(self.compute_api, context, server_id) + context.can( + va_policies.POLICY_ROOT % 'show', + target={'project_id': instance.project_id}) + + volume_id = id + + try: + bdm = objects.BlockDeviceMapping.get_by_volume_and_instance( + context, volume_id, instance.uuid) + except exception.VolumeBDMNotFound: + msg = _( + "Instance %(instance)s is not attached " + "to volume %(volume)s" + ) % {'instance': server_id, 'volume': volume_id} + raise exc.HTTPNotFound(explanation=msg) + + show_tag = api_version_request.is_supported(req, '2.70') + show_delete_on_termination = api_version_request.is_supported( + req, '2.79') + show_attachment_id_bdm_uuid = api_version_request.is_supported( + req, '2.89') + return { + 'volumeAttachment': _translate_attachment_detail_view( + bdm, + show_tag=show_tag, + show_delete_on_termination=show_delete_on_termination, + show_attachment_id_bdm_uuid=show_attachment_id_bdm_uuid, + ) + } + + # TODO(mriedem): This API should return a 202 instead of a 200 response. + @wsgi.expected_errors((400, 403, 404, 409)) + @validation.schema(schema.create, '2.0', '2.48') + @validation.schema(schema.create_v249, '2.49', '2.78') + @validation.schema(schema.create_v279, '2.79') + def create(self, req, server_id, body): + """Attach a volume to an instance.""" + context = req.environ['nova.context'] + instance = common.get_instance(self.compute_api, context, server_id) + context.can( + va_policies.POLICY_ROOT % 'create', + target={'project_id': instance.project_id}) + + volume_id = body['volumeAttachment']['volumeId'] + device = body['volumeAttachment'].get('device') + tag = body['volumeAttachment'].get('tag') + delete_on_termination = body['volumeAttachment'].get( + 'delete_on_termination', False) + + if instance.vm_state in ( + vm_states.SHELVED, vm_states.SHELVED_OFFLOADED, + ): + _check_request_version( + req, '2.20', 'attach_volume', server_id, instance.vm_state) + + try: + supports_multiattach = common.supports_multiattach_volume(req) + device = self.compute_api.attach_volume( + context, instance, volume_id, device, tag=tag, + supports_multiattach=supports_multiattach, + delete_on_termination=delete_on_termination) + except exception.VolumeNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + except (exception.InstanceIsLocked, exception.DevicePathInUse) as e: + raise exc.HTTPConflict(explanation=e.format_message()) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state( + state_error, 'attach_volume', server_id) + except ( + exception.InvalidVolume, + exception.InvalidDevicePath, + exception.InvalidInput, + exception.VolumeTaggedAttachNotSupported, + exception.MultiattachNotSupportedOldMicroversion, + exception.MultiattachToShelvedNotSupported, + ) as e: + raise exc.HTTPBadRequest(explanation=e.format_message()) + except exception.TooManyDiskDevices as e: + raise exc.HTTPForbidden(explanation=e.format_message()) + + # The attach is async + # NOTE(mriedem): It would be nice to use + # _translate_attachment_summary_view here but that does not include + # the 'device' key if device is None or the empty string which would + # be a backward incompatible change. + attachment = {} + attachment['id'] = volume_id + attachment['serverId'] = server_id + attachment['volumeId'] = volume_id + attachment['device'] = device + if api_version_request.is_supported(req, '2.70'): + attachment['tag'] = tag + if api_version_request.is_supported(req, '2.79'): + attachment['delete_on_termination'] = delete_on_termination + return {'volumeAttachment': attachment} + + def _update_volume_swap(self, req, instance, id, body): + context = req.environ['nova.context'] + old_volume_id = id + try: + old_volume = self.volume_api.get(context, old_volume_id) + except exception.VolumeNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + if ( + 'migration_status' not in old_volume or + old_volume['migration_status'] in (None, '') + ): + message = ( + f"volume {old_volume_id} is not migrating; this API " + f"should only be called by Cinder") + raise exc.HTTPConflict(explanation=message) + + new_volume_id = body['volumeAttachment']['volumeId'] + try: + new_volume = self.volume_api.get(context, new_volume_id) + except exception.VolumeNotFound as e: + # NOTE: This BadRequest is different from the above NotFound even + # though the same VolumeNotFound exception. This is intentional + # because new_volume_id is specified in a request body and if a + # nonexistent resource in the body (not URI) the code should be + # 400 Bad Request as API-WG guideline. On the other hand, + # old_volume_id is specified with URI. So it is valid to return + # NotFound response if that is not existent. + raise exc.HTTPBadRequest(explanation=e.format_message()) + + try: + self.compute_api.swap_volume( + context, instance, old_volume, new_volume) + except exception.VolumeBDMNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + except ( + exception.InvalidVolume, + exception.MultiattachSwapVolumeNotSupported, + ) as e: + raise exc.HTTPBadRequest(explanation=e.format_message()) + except exception.InstanceIsLocked as e: + raise exc.HTTPConflict(explanation=e.format_message()) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state( + state_error, 'swap_volume', instance.uuid) + + def _update_volume_regular(self, req, instance, id, body): + context = req.environ['nova.context'] + att = body['volumeAttachment'] + # NOTE(danms): We may be doing an update of regular parameters in + # the midst of a swap operation, so to find the original BDM, we need + # to use the old volume ID, which is the one in the path. + volume_id = id + + try: + bdm = objects.BlockDeviceMapping.get_by_volume_and_instance( + context, volume_id, instance.uuid) + + # NOTE(danms): The attachment id is just the (current) volume id + if 'id' in att and att['id'] != volume_id: + raise exc.HTTPBadRequest( + explanation='The id property is not mutable') + if 'serverId' in att and att['serverId'] != instance.uuid: + raise exc.HTTPBadRequest( + explanation='The serverId property is not mutable') + if 'device' in att and att['device'] != bdm.device_name: + raise exc.HTTPBadRequest( + explanation='The device property is not mutable') + if 'tag' in att and att['tag'] != bdm.tag: + raise exc.HTTPBadRequest( + explanation='The tag property is not mutable') + if 'delete_on_termination' in att: + bdm.delete_on_termination = strutils.bool_from_string( + att['delete_on_termination'], strict=True) + bdm.save() + except exception.VolumeBDMNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + @wsgi.response(202) + @wsgi.expected_errors((400, 404, 409)) + @validation.schema(schema.update, '2.0', '2.84') + @validation.schema(schema.update_v285, '2.85') + def update(self, req, server_id, id, body): + context = req.environ['nova.context'] + instance = common.get_instance(self.compute_api, context, server_id) + attachment = body['volumeAttachment'] + volume_id = attachment['volumeId'] + only_swap = not api_version_request.is_supported(req, '2.85') + + # NOTE(brinzhang): If the 'volumeId' requested by the user is + # different from the 'id' in the url path, or only swap is allowed by + # the microversion, we should check the swap volume policy. + # otherwise, check the volume update policy. + # NOTE(gmann) We pass empty target to policy enforcement. This API + # is called by cinder which does not have correct project_id where + # server belongs to. By passing the empty target, we make sure that + # we do not check the requester project_id and allow users with + # allowed role to perform the swap volume. + if only_swap or id != volume_id: + context.can(va_policies.POLICY_ROOT % 'swap', target={}) + else: + context.can( + va_policies.POLICY_ROOT % 'update', + target={'project_id': instance.project_id}) + + if only_swap: + # NOTE(danms): Original behavior is always call swap on PUT + self._update_volume_swap(req, instance, id, body) + else: + # NOTE(danms): New behavior is update any supported attachment + # properties first, and then call swap if volumeId differs + self._update_volume_regular(req, instance, id, body) + if id != volume_id: + self._update_volume_swap(req, instance, id, body) + + @wsgi.response(202) + @wsgi.expected_errors((400, 403, 404, 409)) + def delete(self, req, server_id, id): + """Detach a volume from an instance.""" + context = req.environ['nova.context'] + instance = common.get_instance( + self.compute_api, context, server_id, + expected_attrs=['device_metadata']) + context.can( + va_policies.POLICY_ROOT % 'delete', + target={'project_id': instance.project_id}) + + volume_id = id + + if instance.vm_state in ( + vm_states.SHELVED, vm_states.SHELVED_OFFLOADED + ): + _check_request_version( + req, '2.20', 'detach_volume', server_id, instance.vm_state) + try: + volume = self.volume_api.get(context, volume_id) + except exception.VolumeNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + try: + bdm = objects.BlockDeviceMapping.get_by_volume_and_instance( + context, volume_id, instance.uuid) + except exception.VolumeBDMNotFound: + msg = _( + "Instance %(instance)s is not attached " + "to volume %(volume)s" + ) % {'instance': server_id, 'volume': volume_id} + raise exc.HTTPNotFound(explanation=msg) + + if bdm.is_root: + msg = _("Cannot detach a root device volume") + raise exc.HTTPBadRequest(explanation=msg) + + try: + self.compute_api.detach_volume(context, instance, volume) + except exception.InvalidVolume as e: + raise exc.HTTPBadRequest(explanation=e.format_message()) + except exception.InvalidInput as e: + raise exc.HTTPBadRequest(explanation=e.format_message()) + except (exception.InstanceIsLocked, exception.ServiceUnavailable) as e: + raise exc.HTTPConflict(explanation=e.format_message()) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state( + state_error, 'detach_volume', server_id) diff --git a/nova/api/openstack/compute/volumes.py b/nova/api/openstack/compute/volumes.py index f8b810e50bbe..dabca6d13940 100644 --- a/nova/api/openstack/compute/volumes.py +++ b/nova/api/openstack/compute/volumes.py @@ -15,35 +15,22 @@ """The volumes extension.""" -from oslo_utils import strutils from webob import exc -from nova.api.openstack import api_version_request from nova.api.openstack.api_version_request \ import MAX_PROXY_API_SUPPORT_VERSION from nova.api.openstack import common -from nova.api.openstack.compute.schemas import volume_attachment as volume_attachment_schema # noqa: E501 -from nova.api.openstack.compute.schemas import volumes as volumes_schema +from nova.api.openstack.compute.schemas import volumes as schema from nova.api.openstack import wsgi from nova.api import validation -from nova.compute import api as compute -from nova.compute import vm_states from nova import exception -from nova.i18n import _ -from nova import objects from nova.policies import volumes as vol_policies -from nova.policies import volumes_attachments as va_policies from nova.volume import cinder def _translate_volume_detail_view(context, vol): """Maps keys for volumes details view.""" - - d = _translate_volume_summary_view(context, vol) - - # No additional data / lookups at the moment - - return d + return _translate_volume_summary_view(context, vol) def _translate_volume_summary_view(context, vol): @@ -106,17 +93,18 @@ class VolumeController(wsgi.Controller): """The Volumes API controller for the OpenStack API.""" def __init__(self): - super(VolumeController, self).__init__() + super().__init__() self.volume_api = cinder.API() @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) @wsgi.expected_errors(404) - @validation.query_schema(volumes_schema.show_query) + @validation.query_schema(schema.show_query) def show(self, req, id): """Return data about the given volume.""" context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'show', - target={'project_id': context.project_id}) + context.can( + vol_policies.POLICY_NAME % 'show', + target={'project_id': context.project_id}) try: vol = self.volume_api.get(context, id) @@ -131,8 +119,9 @@ class VolumeController(wsgi.Controller): def delete(self, req, id): """Delete a volume.""" context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'delete', - target={'project_id': context.project_id}) + context.can( + vol_policies.POLICY_NAME % 'delete', + target={'project_id': context.project_id}) try: self.volume_api.delete(context, id) @@ -143,22 +132,24 @@ class VolumeController(wsgi.Controller): @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) @wsgi.expected_errors(()) - @validation.query_schema(volumes_schema.index_query) + @validation.query_schema(schema.index_query) def index(self, req): """Returns a summary list of volumes.""" context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'list', - target={'project_id': context.project_id}) + context.can( + vol_policies.POLICY_NAME % 'list', + target={'project_id': context.project_id}) return self._items(req, entity_maker=_translate_volume_summary_view) @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) @wsgi.expected_errors(()) - @validation.query_schema(volumes_schema.detail_query) + @validation.query_schema(schema.detail_query) def detail(self, req): """Returns a detailed list of volumes.""" context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'detail', - target={'project_id': context.project_id}) + context.can( + vol_policies.POLICY_NAME % 'detail', + target={'project_id': context.project_id}) return self._items(req, entity_maker=_translate_volume_detail_view) def _items(self, req, entity_maker): @@ -172,7 +163,7 @@ class VolumeController(wsgi.Controller): @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) @wsgi.expected_errors((400, 403, 404)) - @validation.schema(volumes_schema.create) + @validation.schema(schema.create) def create(self, req, body): """Creates a new volume.""" context = req.environ['nova.context'] @@ -208,8 +199,7 @@ class VolumeController(wsgi.Controller): snapshot=snapshot, volume_type=vol_type, metadata=metadata, - availability_zone=availability_zone - ) + availability_zone=availability_zone) except exception.InvalidInput as err: raise exc.HTTPBadRequest(explanation=err.format_message()) except exception.OverQuota as err: @@ -224,479 +214,3 @@ class VolumeController(wsgi.Controller): location = '%s/%s' % (req.url, new_volume['id']) return wsgi.ResponseObject(result, headers=dict(location=location)) - - -def _translate_attachment_detail_view( - bdm, - show_tag=False, - show_delete_on_termination=False, - show_attachment_id_bdm_uuid=False, -): - """Maps keys for attachment details view. - - :param bdm: BlockDeviceMapping object for an attached volume - :param show_tag: True if the "tag" field should be in the response, False - to exclude the "tag" field from the response - :param show_delete_on_termination: True if the "delete_on_termination" - field should be in the response, False to exclude the - "delete_on_termination" field from the response - :param show_attachment_id_bdm_uuid: True if the "attachment_id" and - "bdm_uuid" fields should be in the response. Also controls when the - "id" field is included. - """ - - d = {} - - if not show_attachment_id_bdm_uuid: - d['id'] = bdm.volume_id - - d['volumeId'] = bdm.volume_id - - d['serverId'] = bdm.instance_uuid - - if bdm.device_name: - d['device'] = bdm.device_name - - if show_tag: - d['tag'] = bdm.tag - - if show_delete_on_termination: - d['delete_on_termination'] = bdm.delete_on_termination - - if show_attachment_id_bdm_uuid: - d['attachment_id'] = bdm.attachment_id - d['bdm_uuid'] = bdm.uuid - - return d - - -def _check_request_version(req, min_version, method, server_id, server_state): - if not api_version_request.is_supported(req, min_version): - exc_inv = exception.InstanceInvalidState( - attr='vm_state', - instance_uuid=server_id, - state=server_state, - method=method) - common.raise_http_conflict_for_instance_invalid_state( - exc_inv, - method, - server_id) - - -class VolumeAttachmentController(wsgi.Controller): - """The volume attachment API controller for the OpenStack API. - - A child resource of the server. Note that we use the volume id - as the ID of the attachment (though this is not guaranteed externally) - - """ - - def __init__(self): - self.compute_api = compute.API() - self.volume_api = cinder.API() - super(VolumeAttachmentController, self).__init__() - - @wsgi.expected_errors(404) - @validation.query_schema(volumes_schema.index_query_275, '2.75') - @validation.query_schema(volumes_schema.index_query, '2.0', '2.74') - def index(self, req, server_id): - """Returns the list of volume attachments for a given instance.""" - context = req.environ['nova.context'] - instance = common.get_instance(self.compute_api, context, server_id) - context.can(va_policies.POLICY_ROOT % 'index', - target={'project_id': instance.project_id}) - - bdms = objects.BlockDeviceMappingList.get_by_instance_uuid( - context, instance.uuid) - limited_list = common.limited(bdms, req) - - results = [] - show_tag = api_version_request.is_supported(req, '2.70') - show_delete_on_termination = api_version_request.is_supported( - req, '2.79') - show_attachment_id_bdm_uuid = api_version_request.is_supported( - req, '2.89') - for bdm in limited_list: - if bdm.volume_id: - va = _translate_attachment_detail_view( - bdm, - show_tag=show_tag, - show_delete_on_termination=show_delete_on_termination, - show_attachment_id_bdm_uuid=show_attachment_id_bdm_uuid, - ) - results.append(va) - - return {'volumeAttachments': results} - - @wsgi.expected_errors(404) - @validation.query_schema(volume_attachment_schema.show_query) - def show(self, req, server_id, id): - """Return data about the given volume attachment.""" - context = req.environ['nova.context'] - instance = common.get_instance(self.compute_api, context, server_id) - context.can(va_policies.POLICY_ROOT % 'show', - target={'project_id': instance.project_id}) - - volume_id = id - - try: - bdm = objects.BlockDeviceMapping.get_by_volume_and_instance( - context, volume_id, instance.uuid) - except exception.VolumeBDMNotFound: - msg = (_("Instance %(instance)s is not attached " - "to volume %(volume)s") % - {'instance': server_id, 'volume': volume_id}) - raise exc.HTTPNotFound(explanation=msg) - - show_tag = api_version_request.is_supported(req, '2.70') - show_delete_on_termination = api_version_request.is_supported( - req, '2.79') - show_attachment_id_bdm_uuid = api_version_request.is_supported( - req, '2.89') - return { - 'volumeAttachment': _translate_attachment_detail_view( - bdm, - show_tag=show_tag, - show_delete_on_termination=show_delete_on_termination, - show_attachment_id_bdm_uuid=show_attachment_id_bdm_uuid, - ) - } - - # TODO(mriedem): This API should return a 202 instead of a 200 response. - @wsgi.expected_errors((400, 403, 404, 409)) - @validation.schema(volumes_schema.create_volume_attachment, '2.0', '2.48') - @validation.schema(volumes_schema.create_volume_attachment_v249, '2.49', - '2.78') - @validation.schema(volumes_schema.create_volume_attachment_v279, '2.79') - def create(self, req, server_id, body): - """Attach a volume to an instance.""" - context = req.environ['nova.context'] - instance = common.get_instance(self.compute_api, context, server_id) - context.can(va_policies.POLICY_ROOT % 'create', - target={'project_id': instance.project_id}) - - volume_id = body['volumeAttachment']['volumeId'] - device = body['volumeAttachment'].get('device') - tag = body['volumeAttachment'].get('tag') - delete_on_termination = body['volumeAttachment'].get( - 'delete_on_termination', False) - - if instance.vm_state in (vm_states.SHELVED, - vm_states.SHELVED_OFFLOADED): - _check_request_version(req, '2.20', 'attach_volume', - server_id, instance.vm_state) - - try: - supports_multiattach = common.supports_multiattach_volume(req) - device = self.compute_api.attach_volume( - context, instance, volume_id, device, tag=tag, - supports_multiattach=supports_multiattach, - delete_on_termination=delete_on_termination) - except exception.VolumeNotFound as e: - raise exc.HTTPNotFound(explanation=e.format_message()) - except (exception.InstanceIsLocked, - exception.DevicePathInUse) as e: - raise exc.HTTPConflict(explanation=e.format_message()) - except exception.InstanceInvalidState as state_error: - common.raise_http_conflict_for_instance_invalid_state(state_error, - 'attach_volume', server_id) - except (exception.InvalidVolume, - exception.InvalidDevicePath, - exception.InvalidInput, - exception.VolumeTaggedAttachNotSupported, - exception.MultiattachNotSupportedOldMicroversion, - exception.MultiattachToShelvedNotSupported) as e: - raise exc.HTTPBadRequest(explanation=e.format_message()) - except exception.TooManyDiskDevices as e: - raise exc.HTTPForbidden(explanation=e.format_message()) - - # The attach is async - # NOTE(mriedem): It would be nice to use - # _translate_attachment_summary_view here but that does not include - # the 'device' key if device is None or the empty string which would - # be a backward incompatible change. - attachment = {} - attachment['id'] = volume_id - attachment['serverId'] = server_id - attachment['volumeId'] = volume_id - attachment['device'] = device - if api_version_request.is_supported(req, '2.70'): - attachment['tag'] = tag - if api_version_request.is_supported(req, '2.79'): - attachment['delete_on_termination'] = delete_on_termination - return {'volumeAttachment': attachment} - - def _update_volume_swap(self, req, instance, id, body): - context = req.environ['nova.context'] - old_volume_id = id - try: - old_volume = self.volume_api.get(context, old_volume_id) - except exception.VolumeNotFound as e: - raise exc.HTTPNotFound(explanation=e.format_message()) - - if ('migration_status' not in old_volume or - old_volume['migration_status'] in (None, '')): - message = (f"volume {old_volume_id} is not migrating this api " - "should only be called by Cinder") - raise exc.HTTPConflict(explanation=message) - - new_volume_id = body['volumeAttachment']['volumeId'] - try: - new_volume = self.volume_api.get(context, new_volume_id) - except exception.VolumeNotFound as e: - # NOTE: This BadRequest is different from the above NotFound even - # though the same VolumeNotFound exception. This is intentional - # because new_volume_id is specified in a request body and if a - # nonexistent resource in the body (not URI) the code should be - # 400 Bad Request as API-WG guideline. On the other hand, - # old_volume_id is specified with URI. So it is valid to return - # NotFound response if that is not existent. - raise exc.HTTPBadRequest(explanation=e.format_message()) - - try: - self.compute_api.swap_volume(context, instance, old_volume, - new_volume) - except exception.VolumeBDMNotFound as e: - raise exc.HTTPNotFound(explanation=e.format_message()) - except (exception.InvalidVolume, - exception.MultiattachSwapVolumeNotSupported) as e: - raise exc.HTTPBadRequest(explanation=e.format_message()) - except exception.InstanceIsLocked as e: - raise exc.HTTPConflict(explanation=e.format_message()) - except exception.InstanceInvalidState as state_error: - common.raise_http_conflict_for_instance_invalid_state(state_error, - 'swap_volume', instance.uuid) - - def _update_volume_regular(self, req, instance, id, body): - context = req.environ['nova.context'] - att = body['volumeAttachment'] - # NOTE(danms): We may be doing an update of regular parameters in - # the midst of a swap operation, so to find the original BDM, we need - # to use the old volume ID, which is the one in the path. - volume_id = id - - try: - bdm = objects.BlockDeviceMapping.get_by_volume_and_instance( - context, volume_id, instance.uuid) - - # NOTE(danms): The attachment id is just the (current) volume id - if 'id' in att and att['id'] != volume_id: - raise exc.HTTPBadRequest(explanation='The id property is ' - 'not mutable') - if 'serverId' in att and att['serverId'] != instance.uuid: - raise exc.HTTPBadRequest(explanation='The serverId property ' - 'is not mutable') - if 'device' in att and att['device'] != bdm.device_name: - raise exc.HTTPBadRequest(explanation='The device property is ' - 'not mutable') - if 'tag' in att and att['tag'] != bdm.tag: - raise exc.HTTPBadRequest(explanation='The tag property is ' - 'not mutable') - if 'delete_on_termination' in att: - bdm.delete_on_termination = strutils.bool_from_string( - att['delete_on_termination'], strict=True) - bdm.save() - except exception.VolumeBDMNotFound as e: - raise exc.HTTPNotFound(explanation=e.format_message()) - - @wsgi.response(202) - @wsgi.expected_errors((400, 404, 409)) - @validation.schema(volumes_schema.update_volume_attachment, '2.0', '2.84') - @validation.schema(volumes_schema.update_volume_attachment_v285, '2.85') - def update(self, req, server_id, id, body): - context = req.environ['nova.context'] - instance = common.get_instance(self.compute_api, context, server_id) - attachment = body['volumeAttachment'] - volume_id = attachment['volumeId'] - only_swap = not api_version_request.is_supported(req, '2.85') - - # NOTE(brinzhang): If the 'volumeId' requested by the user is - # different from the 'id' in the url path, or only swap is allowed by - # the microversion, we should check the swap volume policy. - # otherwise, check the volume update policy. - # NOTE(gmann) We pass empty target to policy enforcement. This API - # is called by cinder which does not have correct project_id where - # server belongs to. By passing the empty target, we make sure that - # we do not check the requester project_id and allow users with - # allowed role to perform the swap volume. - if only_swap or id != volume_id: - context.can(va_policies.POLICY_ROOT % 'swap', target={}) - else: - context.can(va_policies.POLICY_ROOT % 'update', - target={'project_id': instance.project_id}) - - if only_swap: - # NOTE(danms): Original behavior is always call swap on PUT - self._update_volume_swap(req, instance, id, body) - else: - # NOTE(danms): New behavior is update any supported attachment - # properties first, and then call swap if volumeId differs - self._update_volume_regular(req, instance, id, body) - if id != volume_id: - self._update_volume_swap(req, instance, id, body) - - @wsgi.response(202) - @wsgi.expected_errors((400, 403, 404, 409)) - def delete(self, req, server_id, id): - """Detach a volume from an instance.""" - context = req.environ['nova.context'] - instance = common.get_instance(self.compute_api, context, server_id, - expected_attrs=['device_metadata']) - context.can(va_policies.POLICY_ROOT % 'delete', - target={'project_id': instance.project_id}) - - volume_id = id - - if instance.vm_state in (vm_states.SHELVED, - vm_states.SHELVED_OFFLOADED): - _check_request_version(req, '2.20', 'detach_volume', - server_id, instance.vm_state) - try: - volume = self.volume_api.get(context, volume_id) - except exception.VolumeNotFound as e: - raise exc.HTTPNotFound(explanation=e.format_message()) - - try: - bdm = objects.BlockDeviceMapping.get_by_volume_and_instance( - context, volume_id, instance.uuid) - except exception.VolumeBDMNotFound: - msg = (_("Instance %(instance)s is not attached " - "to volume %(volume)s") % - {'instance': server_id, 'volume': volume_id}) - raise exc.HTTPNotFound(explanation=msg) - - if bdm.is_root: - msg = _("Cannot detach a root device volume") - raise exc.HTTPBadRequest(explanation=msg) - - try: - self.compute_api.detach_volume(context, instance, volume) - except exception.InvalidVolume as e: - raise exc.HTTPBadRequest(explanation=e.format_message()) - except exception.InvalidInput as e: - raise exc.HTTPBadRequest(explanation=e.format_message()) - except (exception.InstanceIsLocked, exception.ServiceUnavailable) as e: - raise exc.HTTPConflict(explanation=e.format_message()) - except exception.InstanceInvalidState as state_error: - common.raise_http_conflict_for_instance_invalid_state(state_error, - 'detach_volume', server_id) - - -def _translate_snapshot_detail_view(context, vol): - """Maps keys for snapshots details view.""" - - d = _translate_snapshot_summary_view(context, vol) - - # NOTE(gagupta): No additional data / lookups at the moment - return d - - -def _translate_snapshot_summary_view(context, vol): - """Maps keys for snapshots summary view.""" - d = {} - - d['id'] = vol['id'] - d['volumeId'] = vol['volume_id'] - d['status'] = vol['status'] - # NOTE(gagupta): We map volume_size as the snapshot size - d['size'] = vol['volume_size'] - d['createdAt'] = vol['created_at'] - d['displayName'] = vol['display_name'] - d['displayDescription'] = vol['display_description'] - return d - - -class SnapshotController(wsgi.Controller): - """The Snapshots API controller for the OpenStack API.""" - - def __init__(self): - self.volume_api = cinder.API() - super(SnapshotController, self).__init__() - - @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) - @wsgi.expected_errors(404) - @validation.query_schema(volumes_schema.snapshot_show_query) - def show(self, req, id): - """Return data about the given snapshot.""" - context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'snapshots:show', - target={'project_id': context.project_id}) - - try: - vol = self.volume_api.get_snapshot(context, id) - except exception.SnapshotNotFound as e: - raise exc.HTTPNotFound(explanation=e.format_message()) - - return {'snapshot': _translate_snapshot_detail_view(context, vol)} - - @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) - @wsgi.response(202) - @wsgi.expected_errors(404) - def delete(self, req, id): - """Delete a snapshot.""" - context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'snapshots:delete', - target={'project_id': context.project_id}) - - try: - self.volume_api.delete_snapshot(context, id) - except exception.SnapshotNotFound as e: - raise exc.HTTPNotFound(explanation=e.format_message()) - - @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) - @wsgi.expected_errors(()) - @validation.query_schema(volumes_schema.index_query) - def index(self, req): - """Returns a summary list of snapshots.""" - context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'snapshots:list', - target={'project_id': context.project_id}) - return self._items(req, entity_maker=_translate_snapshot_summary_view) - - @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) - @wsgi.expected_errors(()) - @validation.query_schema(volumes_schema.detail_query) - def detail(self, req): - """Returns a detailed list of snapshots.""" - context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'snapshots:detail', - target={'project_id': context.project_id}) - return self._items(req, entity_maker=_translate_snapshot_detail_view) - - def _items(self, req, entity_maker): - """Returns a list of snapshots, transformed through entity_maker.""" - context = req.environ['nova.context'] - - snapshots = self.volume_api.get_all_snapshots(context) - limited_list = common.limited(snapshots, req) - res = [entity_maker(context, snapshot) for snapshot in limited_list] - return {'snapshots': res} - - @wsgi.api_version("2.1", MAX_PROXY_API_SUPPORT_VERSION) - @wsgi.expected_errors((400, 403)) - @validation.schema(volumes_schema.snapshot_create) - def create(self, req, body): - """Creates a new snapshot.""" - context = req.environ['nova.context'] - context.can(vol_policies.POLICY_NAME % 'snapshots:create', - target={'project_id': context.project_id}) - - snapshot = body['snapshot'] - volume_id = snapshot['volume_id'] - - force = snapshot.get('force', False) - force = strutils.bool_from_string(force, strict=True) - if force: - create_func = self.volume_api.create_snapshot_force - else: - create_func = self.volume_api.create_snapshot - - try: - new_snapshot = create_func(context, volume_id, - snapshot.get('display_name'), - snapshot.get('display_description')) - except exception.OverQuota as e: - raise exc.HTTPForbidden(explanation=e.format_message()) - - retval = _translate_snapshot_detail_view(context, new_snapshot) - return {'snapshot': retval} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshot-create-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshot-create-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshot-create-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshot-create-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshot-create-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshot-create-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshot-create-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshot-create-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshots-detail-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshots-detail-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshots-detail-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshots-detail-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshots-list-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshots-list-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshots-list-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshots-list-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshots-show-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshots-show-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/snapshots-show-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-snapshots/snapshots-show-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/attach-volume-to-server-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/attach-volume-to-server-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/attach-volume-to-server-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/attach-volume-to-server-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/attach-volume-to-server-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/attach-volume-to-server-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/attach-volume-to-server-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/attach-volume-to-server-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/list-volume-attachments-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/list-volume-attachments-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/list-volume-attachments-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/list-volume-attachments-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/update-volume-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/update-volume-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/update-volume-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/update-volume-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/attach-volume-to-server-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/attach-volume-to-server-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/attach-volume-to-server-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/attach-volume-to-server-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/attach-volume-to-server-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/list-volume-attachments-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/list-volume-attachments-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/list-volume-attachments-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/list-volume-attachments-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/update-volume-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/update-volume-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/update-volume-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/update-volume-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/volume-attachment-detail-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/volume-attachment-detail-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.49/volume-attachment-detail-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.49/volume-attachment-detail-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/attach-volume-to-server-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/attach-volume-to-server-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/attach-volume-to-server-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/attach-volume-to-server-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/attach-volume-to-server-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/list-volume-attachments-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/list-volume-attachments-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/list-volume-attachments-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/list-volume-attachments-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/update-volume-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/update-volume-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/update-volume-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/update-volume-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/volume-attachment-detail-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/volume-attachment-detail-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.70/volume-attachment-detail-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.70/volume-attachment-detail-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/attach-volume-to-server-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/attach-volume-to-server-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/attach-volume-to-server-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/attach-volume-to-server-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/attach-volume-to-server-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/list-volume-attachments-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/list-volume-attachments-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/list-volume-attachments-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/list-volume-attachments-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/update-volume-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/update-volume-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/update-volume-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/update-volume-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/volume-attachment-detail-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/volume-attachment-detail-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.79/volume-attachment-detail-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.79/volume-attachment-detail-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/attach-volume-to-server-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/attach-volume-to-server-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/attach-volume-to-server-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/attach-volume-to-server-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/attach-volume-to-server-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/list-volume-attachments-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/list-volume-attachments-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/list-volume-attachments-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/list-volume-attachments-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/update-volume-attachment-delete-flag-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/update-volume-attachment-delete-flag-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/update-volume-attachment-delete-flag-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/update-volume-attachment-delete-flag-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/update-volume-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/update-volume-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/update-volume-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/update-volume-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/volume-attachment-detail-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/volume-attachment-detail-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.85/volume-attachment-detail-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.85/volume-attachment-detail-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/attach-volume-to-server-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/attach-volume-to-server-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/attach-volume-to-server-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/attach-volume-to-server-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/attach-volume-to-server-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/list-volume-attachments-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/list-volume-attachments-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/list-volume-attachments-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/list-volume-attachments-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/update-volume-attachment-delete-flag-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/update-volume-attachment-delete-flag-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/update-volume-attachment-delete-flag-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/update-volume-attachment-delete-flag-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/update-volume-req.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/update-volume-req.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/update-volume-req.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/update-volume-req.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/volume-attachment-detail-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/volume-attachment-detail-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/v2.89/volume-attachment-detail-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/v2.89/volume-attachment-detail-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-volumes/volume-attachment-detail-resp.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/volume-attachment-detail-resp.json.tpl similarity index 100% rename from nova/tests/functional/api_sample_tests/api_samples/os-volumes/volume-attachment-detail-resp.json.tpl rename to nova/tests/functional/api_sample_tests/api_samples/os-volume_attachments/volume-attachment-detail-resp.json.tpl diff --git a/nova/tests/functional/api_sample_tests/test_snapshots.py b/nova/tests/functional/api_sample_tests/test_snapshots.py new file mode 100644 index 000000000000..472a57147c99 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/test_snapshots.py @@ -0,0 +1,75 @@ +# Copyright 2012 Nebula, Inc. +# Copyright 2014 IBM Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.tests.functional.api_sample_tests import api_sample_base +from nova.tests.unit.api.openstack import fakes + + +class SnapshotsSampleJsonTests(api_sample_base.ApiSampleTestBaseV21): + sample_dir = "os-snapshots" + + create_subs = { + 'snapshot_name': 'snap-001', + 'description': 'Daily backup', + 'volume_id': '521752a6-acf6-4b2d-bc7a-119f9148cd8c' + } + + def setUp(self): + super().setUp() + + self.stub_out( + "nova.volume.cinder.API.create_snapshot", + fakes.stub_snapshot_create) + self.stub_out( + "nova.volume.cinder.API.delete_snapshot", + fakes.stub_snapshot_delete) + self.stub_out( + "nova.volume.cinder.API.get_all_snapshots", + fakes.stub_snapshot_get_all) + self.stub_out( + "nova.volume.cinder.API.get_snapshot", + fakes.stub_snapshot_get) + + def _create_snapshot(self): + response = self._do_post( + "os-snapshots", "snapshot-create-req", self.create_subs) + return response + + def test_snapshots_create(self): + response = self._create_snapshot() + self._verify_response( + "snapshot-create-resp", self.create_subs, response, 200) + + def test_snapshots_delete(self): + self._create_snapshot() + response = self._do_delete('os-snapshots/100') + self.assertEqual(202, response.status_code) + self.assertEqual('', response.text) + + def test_snapshots_detail(self): + response = self._do_get('os-snapshots/detail') + self._verify_response('snapshots-detail-resp', {}, response, 200) + + def test_snapshots_list(self): + response = self._do_get('os-snapshots') + self._verify_response('snapshots-list-resp', {}, response, 200) + + def test_snapshots_show(self): + response = self._do_get('os-snapshots/100') + subs = { + 'snapshot_name': 'Default name', + 'description': 'Default description' + } + self._verify_response('snapshots-show-resp', subs, response, 200) diff --git a/nova/tests/functional/api_sample_tests/test_volume_attachments.py b/nova/tests/functional/api_sample_tests/test_volume_attachments.py new file mode 100644 index 000000000000..d2edecfcef61 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/test_volume_attachments.py @@ -0,0 +1,161 @@ +# Copyright 2012 Nebula, Inc. +# Copyright 2014 IBM Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from nova.tests import fixtures +from nova.tests.functional.api_sample_tests import test_servers + + +class VolumeAttachmentsSample(test_servers.ServersSampleBase): + sample_dir = "os-volume_attachments" + + # The 'os_compute_api:os-volumes-attachments:swap' policy is admin-only + ADMIN_API = True + + OLD_VOLUME_ID = fixtures.CinderFixture.SWAP_OLD_VOL + NEW_VOLUME_ID = fixtures.CinderFixture.SWAP_NEW_VOL + + def setUp(self): + super().setUp() + self.cinder = self.useFixture(fixtures.CinderFixture(self)) + self.server_id = self._post_server() + + def _get_vol_attachment_subs(self, subs): + """Allows subclasses to override/supplement request/response subs""" + return subs + + def test_attach_volume_to_server(self): + subs = { + 'volume_id': self.OLD_VOLUME_ID, + 'device': '/dev/sdb' + } + subs = self._get_vol_attachment_subs(subs) + response = self._do_post( + 'servers/%s/os-volume_attachments' % self.server_id, + 'attach-volume-to-server-req', subs) + self._verify_response( + 'attach-volume-to-server-resp', subs, response, 200) + return subs + + def test_list_volume_attachments(self): + subs = self.test_attach_volume_to_server() + # Attach another volume to the server so the response has multiple + # which is more interesting since it's a list of dicts. + body = { + 'volumeAttachment': { + 'volumeId': self.NEW_VOLUME_ID + } + } + self.api.post_server_volume(self.server_id, body) + response = self._do_get( + 'servers/%s/os-volume_attachments' % self.server_id) + subs['volume_id2'] = self.NEW_VOLUME_ID + self._verify_response( + 'list-volume-attachments-resp', subs, response, 200) + + def test_volume_attachment_detail(self): + subs = self.test_attach_volume_to_server() + response = self._do_get( + 'servers/%s/os-volume_attachments/%s' % ( + self.server_id, subs['volume_id'])) + self._verify_response( + 'volume-attachment-detail-resp', subs, response, 200) + + def test_volume_attachment_delete(self): + subs = self.test_attach_volume_to_server() + response = self._do_delete( + 'servers/%s/os-volume_attachments/%s' % ( + self.server_id, subs['volume_id'])) + self.assertEqual(202, response.status_code) + self.assertEqual('', response.text) + + def test_volume_attachment_update(self): + subs = self.test_attach_volume_to_server() + subs['new_volume_id'] = self.NEW_VOLUME_ID + response = self._do_put( + 'servers/%s/os-volume_attachments/%s' % ( + self.server_id, subs['volume_id']), + 'update-volume-req', subs) + self.assertEqual(202, response.status_code) + self.assertEqual('', response.text) + + +class VolumeAttachmentsSampleV249(VolumeAttachmentsSample): + """Microversion 2.49 adds the "tag" parameter to the request body""" + microversion = '2.49' + scenarios = [('v2_49', {'api_major_version': 'v2.1'})] + + def setUp(self): + super().setUp() + # Stub out ComputeManager._delete_disk_metadata since the fake virt + # driver does not actually update the instance.device_metadata.devices + # list with the tagged bdm disk device metadata. + self.stub_out( + 'nova.compute.manager.ComputeManager._delete_disk_metadata', + lambda *a, **kw: None) + + def _get_vol_attachment_subs(self, subs): + return dict(subs, tag='foo') + + +class VolumeAttachmentsSampleV270(VolumeAttachmentsSampleV249): + """Microversion 2.70 adds the "tag" parameter to the response body""" + microversion = '2.70' + scenarios = [('v2_70', {'api_major_version': 'v2.1'})] + + +class VolumeAttachmentsSampleV279(VolumeAttachmentsSampleV270): + """Microversion 2.79 adds the "delete_on_termination" parameter to the + request and response body. + """ + microversion = '2.79' + scenarios = [('v2_79', {'api_major_version': 'v2.1'})] + + +class VolumeAttachmentsSampleV285(VolumeAttachmentsSampleV279): + """Microversion 2.85 adds the ``PUT + /servers/{server_id}/os-volume_attachments/{volume_id}`` + support for specifying ``delete_on_termination`` field in the request + body to re-config the attached volume whether to delete when the instance + is deleted. + """ + microversion = '2.85' + scenarios = [('v2_85', {'api_major_version': 'v2.1'})] + + def test_volume_attachment_update(self): + subs = self.test_attach_volume_to_server() + attached_volume_id = subs['volume_id'] + subs['server_id'] = self.server_id + response = self._do_put( + 'servers/%s/os-volume_attachments/%s' % ( + self.server_id, attached_volume_id), + 'update-volume-attachment-delete-flag-req', subs) + self.assertEqual(202, response.status_code) + self.assertEqual('', response.text) + + # Make sure the attached volume was changed + attachments = self.api.api_get( + '/servers/%s/os-volume_attachments' % self.server_id + ).body['volumeAttachments'] + self.assertEqual(1, len(attachments)) + self.assertEqual(self.server_id, attachments[0]['serverId']) + self.assertTrue(attachments[0]['delete_on_termination']) + + +class VolumeAttachmentsSampleV289(VolumeAttachmentsSampleV285): + """Microversion 2.89 adds the "attachment_id" parameter to the + response body of show and list. + """ + microversion = '2.89' + scenarios = [('v2_89', {'api_major_version': 'v2.1'})] diff --git a/nova/tests/functional/api_sample_tests/test_volumes.py b/nova/tests/functional/api_sample_tests/test_volumes.py index c71a254c2bf6..1c9ab80f16fa 100644 --- a/nova/tests/functional/api_sample_tests/test_volumes.py +++ b/nova/tests/functional/api_sample_tests/test_volumes.py @@ -15,67 +15,10 @@ import datetime -from nova.tests import fixtures -from nova.tests.functional.api_sample_tests import api_sample_base from nova.tests.functional.api_sample_tests import test_servers from nova.tests.unit.api.openstack import fakes -class SnapshotsSampleJsonTests(api_sample_base.ApiSampleTestBaseV21): - sample_dir = "os-volumes" - - create_subs = { - 'snapshot_name': 'snap-001', - 'description': 'Daily backup', - 'volume_id': '521752a6-acf6-4b2d-bc7a-119f9148cd8c' - } - - def setUp(self): - super(SnapshotsSampleJsonTests, self).setUp() - self.stub_out("nova.volume.cinder.API.get_all_snapshots", - fakes.stub_snapshot_get_all) - self.stub_out("nova.volume.cinder.API.get_snapshot", - fakes.stub_snapshot_get) - - def _create_snapshot(self): - self.stub_out("nova.volume.cinder.API.create_snapshot", - fakes.stub_snapshot_create) - - response = self._do_post("os-snapshots", - "snapshot-create-req", - self.create_subs) - return response - - def test_snapshots_create(self): - response = self._create_snapshot() - self._verify_response("snapshot-create-resp", - self.create_subs, response, 200) - - def test_snapshots_delete(self): - self.stub_out("nova.volume.cinder.API.delete_snapshot", - fakes.stub_snapshot_delete) - self._create_snapshot() - response = self._do_delete('os-snapshots/100') - self.assertEqual(202, response.status_code) - self.assertEqual('', response.text) - - def test_snapshots_detail(self): - response = self._do_get('os-snapshots/detail') - self._verify_response('snapshots-detail-resp', {}, response, 200) - - def test_snapshots_list(self): - response = self._do_get('os-snapshots') - self._verify_response('snapshots-list-resp', {}, response, 200) - - def test_snapshots_show(self): - response = self._do_get('os-snapshots/100') - subs = { - 'snapshot_name': 'Default name', - 'description': 'Default description' - } - self._verify_response('snapshots-show-resp', subs, response, 200) - - def _get_volume_id(): return 'a26887c6-c47b-4654-abb5-dfadf7d3f803' @@ -130,7 +73,7 @@ class VolumesSampleJsonTest(test_servers.ServersSampleBase): sample_dir = "os-volumes" def setUp(self): - super(VolumesSampleJsonTest, self).setUp() + super().setUp() fakes.stub_out_networking(self) self.stub_out("nova.volume.cinder.API.delete", @@ -187,143 +130,3 @@ class VolumesSampleJsonTest(test_servers.ServersSampleBase): response = self._do_delete('os-volumes/%s' % vol_id) self.assertEqual(202, response.status_code) self.assertEqual('', response.text) - - -class VolumeAttachmentsSample(test_servers.ServersSampleBase): - # The 'os_compute_api:os-volumes-attachments:swap' policy is admin-only - ADMIN_API = True - sample_dir = "os-volumes" - - OLD_VOLUME_ID = fixtures.CinderFixture.SWAP_OLD_VOL - NEW_VOLUME_ID = fixtures.CinderFixture.SWAP_NEW_VOL - - def setUp(self): - super(VolumeAttachmentsSample, self).setUp() - self.cinder = self.useFixture(fixtures.CinderFixture(self)) - self.server_id = self._post_server() - - def _get_vol_attachment_subs(self, subs): - """Allows subclasses to override/supplement request/response subs""" - return subs - - def test_attach_volume_to_server(self): - subs = { - 'volume_id': self.OLD_VOLUME_ID, - 'device': '/dev/sdb' - } - subs = self._get_vol_attachment_subs(subs) - response = self._do_post('servers/%s/os-volume_attachments' - % self.server_id, - 'attach-volume-to-server-req', subs) - self._verify_response('attach-volume-to-server-resp', subs, - response, 200) - return subs - - def test_list_volume_attachments(self): - subs = self.test_attach_volume_to_server() - # Attach another volume to the server so the response has multiple - # which is more interesting since it's a list of dicts. - body = { - 'volumeAttachment': { - 'volumeId': self.NEW_VOLUME_ID - } - } - self.api.post_server_volume(self.server_id, body) - response = self._do_get('servers/%s/os-volume_attachments' - % self.server_id) - subs['volume_id2'] = self.NEW_VOLUME_ID - self._verify_response('list-volume-attachments-resp', subs, - response, 200) - - def test_volume_attachment_detail(self): - subs = self.test_attach_volume_to_server() - response = self._do_get('servers/%s/os-volume_attachments/%s' - % (self.server_id, subs['volume_id'])) - self._verify_response('volume-attachment-detail-resp', subs, - response, 200) - - def test_volume_attachment_delete(self): - subs = self.test_attach_volume_to_server() - response = self._do_delete('servers/%s/os-volume_attachments/%s' - % (self.server_id, subs['volume_id'])) - self.assertEqual(202, response.status_code) - self.assertEqual('', response.text) - - def test_volume_attachment_update(self): - subs = self.test_attach_volume_to_server() - subs['new_volume_id'] = self.NEW_VOLUME_ID - response = self._do_put('servers/%s/os-volume_attachments/%s' - % (self.server_id, subs['volume_id']), - 'update-volume-req', - subs) - self.assertEqual(202, response.status_code) - self.assertEqual('', response.text) - - -class VolumeAttachmentsSampleV249(VolumeAttachmentsSample): - sample_dir = "os-volumes" - microversion = '2.49' - scenarios = [('v2_49', {'api_major_version': 'v2.1'})] - - def setUp(self): - super(VolumeAttachmentsSampleV249, self).setUp() - # Stub out ComputeManager._delete_disk_metadata since the fake virt - # driver does not actually update the instance.device_metadata.devices - # list with the tagged bdm disk device metadata. - self.stub_out('nova.compute.manager.ComputeManager.' - '_delete_disk_metadata', lambda *a, **kw: None) - - def _get_vol_attachment_subs(self, subs): - return dict(subs, tag='foo') - - -class VolumeAttachmentsSampleV270(VolumeAttachmentsSampleV249): - """2.70 adds the "tag" parameter to the response body""" - microversion = '2.70' - scenarios = [('v2_70', {'api_major_version': 'v2.1'})] - - -class VolumeAttachmentsSampleV279(VolumeAttachmentsSampleV270): - """Microversion 2.79 adds the "delete_on_termination" parameter to the - request and response body. - """ - microversion = '2.79' - scenarios = [('v2_79', {'api_major_version': 'v2.1'})] - - -class UpdateVolumeAttachmentsSampleV285(VolumeAttachmentsSampleV279): - """Microversion 2.85 adds the ``PUT - /servers/{server_id}/os-volume_attachments/{volume_id}`` - support for specifying ``delete_on_termination`` field in the request - body to re-config the attached volume whether to delete when the instance - is deleted. - """ - microversion = '2.85' - scenarios = [('v2_85', {'api_major_version': 'v2.1'})] - - def test_volume_attachment_update(self): - subs = self.test_attach_volume_to_server() - attached_volume_id = subs['volume_id'] - subs['server_id'] = self.server_id - response = self._do_put('servers/%s/os-volume_attachments/%s' - % (self.server_id, attached_volume_id), - 'update-volume-attachment-delete-flag-req', - subs) - self.assertEqual(202, response.status_code) - self.assertEqual('', response.text) - - # Make sure the attached volume was changed - attachments = self.api.api_get( - '/servers/%s/os-volume_attachments' % self.server_id).body[ - 'volumeAttachments'] - self.assertEqual(1, len(attachments)) - self.assertEqual(self.server_id, attachments[0]['serverId']) - self.assertTrue(attachments[0]['delete_on_termination']) - - -class VolumeAttachmentsSampleV289(UpdateVolumeAttachmentsSampleV285): - """Microversion 2.89 adds the "attachment_id" parameter to the - response body of show and list. - """ - microversion = '2.89' - scenarios = [('v2_89', {'api_major_version': 'v2.1'})] diff --git a/nova/tests/functional/regressions/test_bug_1943431.py b/nova/tests/functional/regressions/test_bug_1943431.py index 5e945de0c9cb..dd7d0a2b9cea 100644 --- a/nova/tests/functional/regressions/test_bug_1943431.py +++ b/nova/tests/functional/regressions/test_bug_1943431.py @@ -67,7 +67,7 @@ class TestLibvirtROMultiattachMigrate( client.OpenStackApiException, self.api.put_server_volume, server_id, self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL, self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL) - self.assertIn("this api should only be called by Cinder", str(ex)) + self.assertIn("this API should only be called by Cinder", str(ex)) def test_ro_multiattach_migrate_volume(self): server_id = self._create_server(networks='none')['id'] diff --git a/nova/tests/functional/regressions/test_bug_2112187.py b/nova/tests/functional/regressions/test_bug_2112187.py index 276e919d4ecd..d004f58635c5 100644 --- a/nova/tests/functional/regressions/test_bug_2112187.py +++ b/nova/tests/functional/regressions/test_bug_2112187.py @@ -64,4 +64,4 @@ class TestDirectSwapVolume( client.OpenStackApiException, self.api.put_server_volume, server_id, self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL, self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL) - self.assertIn("this api should only be called by Cinder", str(ex)) + self.assertIn("this API should only be called by Cinder", str(ex)) diff --git a/nova/tests/unit/api/openstack/compute/test_assisted_snapshots.py b/nova/tests/unit/api/openstack/compute/test_assisted_snapshots.py new file mode 100644 index 000000000000..cc1ad65ebbe3 --- /dev/null +++ b/nova/tests/unit/api/openstack/compute/test_assisted_snapshots.py @@ -0,0 +1,255 @@ +# Copyright 2013 Josh Durgin +# Copyright 2013 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock +import urllib + +import fixtures +from oslo_serialization import jsonutils +from oslo_utils.fixture import uuidsentinel as uuids +import webob + +from nova.api.openstack.compute import assisted_volume_snapshots \ + as assisted_snaps_v21 +from nova.compute import api as compute_api +from nova.compute import task_states +from nova import exception +from nova import test +from nova.tests.unit.api.openstack import fakes + +FAKE_UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + + +class AssistedSnapshotCreateTestCaseV21(test.NoDBTestCase): + assisted_snaps = assisted_snaps_v21 + bad_request = exception.ValidationError + + def setUp(self): + super(AssistedSnapshotCreateTestCaseV21, self).setUp() + + self.controller = \ + self.assisted_snaps.AssistedVolumeSnapshotsController() + self.url = ('/v2/%s/os-assisted-volume-snapshots' % + fakes.FAKE_PROJECT_ID) + + @mock.patch.object(compute_api.API, 'volume_snapshot_create') + def test_assisted_create(self, mock_volume_snapshot_create): + mock_volume_snapshot_create.return_value = { + 'snapshot': { + 'id': uuids.snapshot_id, + 'volumeId': uuids.volume_id, + }, + } + req = fakes.HTTPRequest.blank(self.url) + expected_create_info = {'type': 'qcow2', + 'new_file': 'new_file', + 'snapshot_id': 'snapshot_id'} + body = {'snapshot': {'volume_id': uuids.volume_to_snapshot, + 'create_info': expected_create_info}} + req.method = 'POST' + self.controller.create(req, body=body) + + mock_volume_snapshot_create.assert_called_once_with( + req.environ['nova.context'], uuids.volume_to_snapshot, + expected_create_info) + + def test_assisted_create_missing_create_info(self): + req = fakes.HTTPRequest.blank(self.url) + body = {'snapshot': {'volume_id': '1'}} + req.method = 'POST' + self.assertRaises(self.bad_request, self.controller.create, + req, body=body) + + def test_assisted_create_with_unexpected_attr(self): + req = fakes.HTTPRequest.blank(self.url) + body = { + 'snapshot': { + 'volume_id': '1', + 'create_info': { + 'type': 'qcow2', + 'new_file': 'new_file', + 'snapshot_id': 'snapshot_id' + } + }, + 'unexpected': 0, + } + req.method = 'POST' + self.assertRaises(self.bad_request, self.controller.create, + req, body=body) + + @mock.patch('nova.objects.BlockDeviceMapping.get_by_volume', + side_effect=exception.VolumeBDMIsMultiAttach(volume_id='1')) + def test_assisted_create_multiattach_fails(self, bdm_get_by_volume): + req = fakes.HTTPRequest.blank(self.url) + body = {'snapshot': + {'volume_id': '1', + 'create_info': {'type': 'qcow2', + 'new_file': 'new_file', + 'snapshot_id': 'snapshot_id'}}} + req.method = 'POST' + self.assertRaises( + webob.exc.HTTPBadRequest, self.controller.create, req, body=body) + + def _test_assisted_create_instance_conflict(self, api_error): + req = fakes.HTTPRequest.blank(self.url) + body = {'snapshot': + {'volume_id': '1', + 'create_info': {'type': 'qcow2', + 'new_file': 'new_file', + 'snapshot_id': 'snapshot_id'}}} + req.method = 'POST' + with mock.patch.object(compute_api.API, 'volume_snapshot_create', + side_effect=api_error): + self.assertRaises( + webob.exc.HTTPBadRequest, self.controller.create, + req, body=body) + + def test_assisted_create_instance_invalid_state(self): + api_error = exception.InstanceInvalidState( + instance_uuid=FAKE_UUID, attr='task_state', + state=task_states.SHELVING_OFFLOADING, + method='volume_snapshot_create') + self._test_assisted_create_instance_conflict(api_error) + + def test_assisted_create_instance_not_ready(self): + api_error = exception.InstanceNotReady(instance_id=FAKE_UUID) + self._test_assisted_create_instance_conflict(api_error) + + +class AssistedSnapshotDeleteTestCaseV21(test.NoDBTestCase): + assisted_snaps = assisted_snaps_v21 + microversion = '2.1' + + def _check_status(self, expected_status, req, res, controller_method): + self.assertEqual(expected_status, controller_method.wsgi_codes(req)) + + def setUp(self): + super(AssistedSnapshotDeleteTestCaseV21, self).setUp() + + self.controller = \ + self.assisted_snaps.AssistedVolumeSnapshotsController() + self.mock_volume_snapshot_delete = self.useFixture( + fixtures.MockPatchObject(compute_api.API, + 'volume_snapshot_delete')).mock + self.url = ('/v2/%s/os-assisted-volume-snapshots' % + fakes.FAKE_PROJECT_ID) + + def test_assisted_delete(self): + params = { + 'delete_info': jsonutils.dumps({'volume_id': '1'}), + } + req = fakes.HTTPRequest.blank( + self.url + '?%s' % + urllib.parse.urlencode(params), + version=self.microversion) + req.method = 'DELETE' + result = self.controller.delete(req, '5') + self._check_status(204, req, result, self.controller.delete) + + def test_assisted_delete_missing_delete_info(self): + req = fakes.HTTPRequest.blank(self.url, + version=self.microversion) + req.method = 'DELETE' + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.delete, + req, '5') + + def _test_assisted_delete_instance_conflict(self, api_error): + self.mock_volume_snapshot_delete.side_effect = api_error + params = { + 'delete_info': jsonutils.dumps({'volume_id': '1'}), + } + req = fakes.HTTPRequest.blank( + self.url + '?%s' % + urllib.parse.urlencode(params), + version=self.microversion) + req.method = 'DELETE' + + self.assertRaises( + webob.exc.HTTPBadRequest, self.controller.delete, req, '5') + + def test_assisted_delete_instance_invalid_state(self): + api_error = exception.InstanceInvalidState( + instance_uuid=FAKE_UUID, attr='task_state', + state=task_states.UNSHELVING, + method='volume_snapshot_delete') + self._test_assisted_delete_instance_conflict(api_error) + + def test_assisted_delete_instance_not_ready(self): + api_error = exception.InstanceNotReady(instance_id=FAKE_UUID) + self._test_assisted_delete_instance_conflict(api_error) + + def test_delete_additional_query_parameters(self): + params = { + 'delete_info': jsonutils.dumps({'volume_id': '1'}), + 'additional': 123 + } + req = fakes.HTTPRequest.blank( + self.url + '?%s' % + urllib.parse.urlencode(params), + version=self.microversion) + req.method = 'DELETE' + self.controller.delete(req, '5') + + def test_delete_duplicate_query_parameters_validation(self): + params = [ + ('delete_info', jsonutils.dumps({'volume_id': '1'})), + ('delete_info', jsonutils.dumps({'volume_id': '2'})) + ] + req = fakes.HTTPRequest.blank( + self.url + '?%s' % + urllib.parse.urlencode(params), + version=self.microversion) + req.method = 'DELETE' + self.controller.delete(req, '5') + + def test_assisted_delete_missing_volume_id(self): + params = { + 'delete_info': jsonutils.dumps({'something_else': '1'}), + } + req = fakes.HTTPRequest.blank( + self.url + '?%s' % + urllib.parse.urlencode(params), + version=self.microversion) + + req.method = 'DELETE' + ex = self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.delete, req, '5') + # This is the result of a KeyError but the only thing in the message + # is the missing key. + self.assertIn('volume_id', str(ex)) + + +class AssistedSnapshotDeleteTestCaseV275(AssistedSnapshotDeleteTestCaseV21): + assisted_snaps = assisted_snaps_v21 + microversion = '2.75' + + def test_delete_additional_query_parameters_old_version(self): + params = { + 'delete_info': jsonutils.dumps({'volume_id': '1'}), + 'additional': 123 + } + req = fakes.HTTPRequest.blank( + self.url + '?%s' % + urllib.parse.urlencode(params), + version='2.74') + self.controller.delete(req, 1) + + def test_delete_additional_query_parameters(self): + req = fakes.HTTPRequest.blank( + self.url + '?unknown=1', + version=self.microversion) + self.assertRaises(exception.ValidationError, + self.controller.delete, req, 1) diff --git a/nova/tests/unit/api/openstack/compute/test_snapshots.py b/nova/tests/unit/api/openstack/compute/test_snapshots.py index 7afa2eeb280e..b493187e0b1d 100644 --- a/nova/tests/unit/api/openstack/compute/test_snapshots.py +++ b/nova/tests/unit/api/openstack/compute/test_snapshots.py @@ -17,7 +17,7 @@ from unittest import mock import webob -from nova.api.openstack.compute import volumes as volumes_v21 +from nova.api.openstack.compute import snapshots from nova import exception from nova import test from nova.tests.unit.api.openstack import fakes @@ -27,11 +27,9 @@ FAKE_UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' class SnapshotApiTestV21(test.NoDBTestCase): - controller = volumes_v21.SnapshotController() - validation_error = exception.ValidationError def setUp(self): - super(SnapshotApiTestV21, self).setUp() + super().setUp() fakes.stub_out_networking(self) self.stub_out("nova.volume.cinder.API.create_snapshot", fakes.stub_snapshot_create) @@ -44,6 +42,7 @@ class SnapshotApiTestV21(test.NoDBTestCase): self.stub_out("nova.volume.cinder.API.get_all_snapshots", fakes.stub_snapshot_get_all) self.stub_out("nova.volume.cinder.API.get", fakes.stub_volume_get) + self.controller = snapshots.SnapshotController() self.req = fakes.HTTPRequest.blank('') def _test_snapshot_create(self, force): @@ -70,9 +69,26 @@ class SnapshotApiTestV21(test.NoDBTestCase): def test_snapshot_create_invalid_force_param(self): body = {'snapshot': {'volume_id': '1', 'force': '**&&^^%%$$##@@'}} - self.assertRaises(self.validation_error, + self.assertRaises(exception.ValidationError, self.controller.create, self.req, body=body) + def test_create_no_body(self): + self.assertRaises( + exception.ValidationError, + self.controller.create, self.req, body=None) + + def test_create_missing_volume(self): + body = {'foo': {'a': 'b'}} + self.assertRaises( + exception.ValidationError, + self.controller.create, self.req, body=body) + + def test_create_malformed_entity(self): + body = {'snapshot': 'string'} + self.assertRaises( + exception.ValidationError, + self.controller.create, self.req, body=body) + def test_snapshot_delete(self): snapshot_id = '123' delete = self.controller.delete @@ -80,7 +96,7 @@ class SnapshotApiTestV21(test.NoDBTestCase): # NOTE: on v2.1, http status code is set as wsgi_codes of API # method instead of status_int in a response object. - if isinstance(self.controller, volumes_v21.SnapshotController): + if isinstance(self.controller, snapshots.SnapshotController): status_int = delete.wsgi_codes(self.req) else: status_int = result.status_int @@ -224,8 +240,8 @@ class SnapshotApiTestV21(test.NoDBTestCase): class TestSnapshotAPIDeprecation(test.NoDBTestCase): def setUp(self): - super(TestSnapshotAPIDeprecation, self).setUp() - self.controller = volumes_v21.SnapshotController() + super().setUp() + self.controller = snapshots.SnapshotController() self.req = fakes.HTTPRequest.blank('', version='2.36') def test_all_apis_return_not_found(self): diff --git a/nova/tests/unit/api/openstack/compute/test_volume_attachments.py b/nova/tests/unit/api/openstack/compute/test_volume_attachments.py new file mode 100644 index 000000000000..5ce1bceee21c --- /dev/null +++ b/nova/tests/unit/api/openstack/compute/test_volume_attachments.py @@ -0,0 +1,1396 @@ +# Copyright 2013 Josh Durgin +# Copyright 2013 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime +from unittest import mock + +from oslo_serialization import jsonutils +from oslo_utils.fixture import uuidsentinel as uuids +import webob +from webob import exc + +from nova.api.openstack import api_version_request +from nova.api.openstack import common +from nova.api.openstack.compute import volume_attachments +from nova.compute import api as compute_api +from nova.compute import vm_states +from nova import context +from nova import exception +from nova import objects +from nova.objects import block_device as block_device_obj +from nova import test +from nova.tests.unit.api.openstack import fakes +from nova.tests.unit import fake_block_device +from nova.tests.unit import fake_instance +from nova.volume import cinder + +# This is the server ID. +FAKE_UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +# This is the old volume ID (to swap from). +FAKE_UUID_A = '00000000-aaaa-aaaa-aaaa-000000000000' +# This is the new volume ID (to swap to). +FAKE_UUID_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' +# This is a volume that is not found. +FAKE_UUID_C = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + + +def fake_get_instance(self, context, instance_id, expected_attrs=None, + cell_down_support=False): + return fake_instance.fake_instance_obj( + context, id=1, uuid=instance_id, project_id=context.project_id) + + +# TODO(sean-k-mooney): this is duplicated in the policy tests +# we should consider consolidating this. + +def fake_get_volume(self, context, id): + migration_status = None + if id == FAKE_UUID_A: + status = 'in-use' + attach_status = 'attached' + elif id == FAKE_UUID_B: + status = 'available' + attach_status = 'detached' + elif id == uuids.source_swap_vol: + status = 'in-use' + attach_status = 'attached' + migration_status = 'migrating' + else: + raise exception.VolumeNotFound(volume_id=id) + return { + 'id': id, 'status': status, 'attach_status': attach_status, + 'migration_status': migration_status + } + + +@classmethod +def fake_bdm_get_by_volume_and_instance(cls, ctxt, volume_id, instance_uuid): + if volume_id not in (FAKE_UUID_A, uuids.source_swap_vol): + raise exception.VolumeBDMNotFound(volume_id=volume_id) + db_bdm = fake_block_device.FakeDbBlockDeviceDict({ + 'id': 1, + 'uuid': uuids.bdm, + 'instance_uuid': instance_uuid, + 'device_name': '/dev/fake0', + 'delete_on_termination': 'False', + 'source_type': 'volume', + 'destination_type': 'volume', + 'snapshot_id': None, + 'volume_id': volume_id, + 'volume_size': 1, + 'attachment_id': uuids.attachment_id + }) + return objects.BlockDeviceMapping._from_db_object( + ctxt, objects.BlockDeviceMapping(), db_bdm) + + +class VolumeAttachTestsV21(test.NoDBTestCase): + validation_error = exception.ValidationError + microversion = '2.1' + _prefix = '/servers/id/os-volume_attachments' + + def setUp(self): + super().setUp() + self.stub_out('nova.objects.BlockDeviceMapping' + '.get_by_volume_and_instance', + fake_bdm_get_by_volume_and_instance) + self.stub_out('nova.compute.api.API.get', fake_get_instance) + self.stub_out('nova.volume.cinder.API.get', fake_get_volume) + self.context = context.get_admin_context() + self.expected_show = {'volumeAttachment': { + 'device': '/dev/fake0', + 'serverId': FAKE_UUID, + 'id': FAKE_UUID_A, + 'volumeId': FAKE_UUID_A}} + self.controller = volume_attachments.VolumeAttachmentController() + + self.req = self._build_request('/uuid') + self.req.body = jsonutils.dump_as_bytes({}) + self.req.headers['content-type'] = 'application/json' + self.req.environ['nova.context'] = self.context + + def _build_request(self, url=''): + return fakes.HTTPRequest.blank( + self._prefix + url, version=self.microversion) + + def test_show(self): + result = self.controller.show(self.req, FAKE_UUID, FAKE_UUID_A) + self.assertEqual(self.expected_show, result) + + @mock.patch.object( + compute_api.API, 'get', + side_effect=exception.InstanceNotFound(instance_id=FAKE_UUID)) + def test_show_no_instance(self, mock_mr): + self.assertRaises(exc.HTTPNotFound, + self.controller.show, + self.req, + FAKE_UUID, + FAKE_UUID_A) + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance', + side_effect=exception.VolumeBDMNotFound( + volume_id=FAKE_UUID_A)) + def test_show_no_bdms(self, mock_mr): + self.assertRaises(exc.HTTPNotFound, + self.controller.show, + self.req, + FAKE_UUID, + FAKE_UUID_A) + + def test_show_bdms_no_mountpoint(self): + FAKE_UUID_NOTEXIST = '00000000-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + + self.assertRaises(exc.HTTPNotFound, + self.controller.show, + self.req, + FAKE_UUID, + FAKE_UUID_NOTEXIST) + + def test_detach(self): + self.stub_out('nova.compute.api.API.detach_volume', + lambda self, context, instance, volume: None) + inst = fake_instance.fake_instance_obj(self.context, + **{'uuid': FAKE_UUID}) + with mock.patch.object(common, 'get_instance', + return_value=inst) as mock_get_instance: + result = self.controller.delete(self.req, FAKE_UUID, FAKE_UUID_A) + # NOTE: on v2.1, http status code is set as wsgi_codes of API + # method instead of status_int in a response object. + if isinstance(self.controller, + volume_attachments.VolumeAttachmentController): + status_int = self.controller.delete.wsgi_codes(self.req) + else: + status_int = result.status_int + self.assertEqual(202, status_int) + mock_get_instance.assert_called_with( + self.controller.compute_api, self.context, FAKE_UUID, + expected_attrs=['device_metadata']) + + @mock.patch.object(common, 'get_instance') + def test_detach_vol_shelved_not_supported(self, mock_get_instance): + inst = fake_instance.fake_instance_obj(self.context, + **{'uuid': FAKE_UUID}) + inst.vm_state = vm_states.SHELVED + mock_get_instance.return_value = inst + req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments/uuid', version='2.19') + req.method = 'DELETE' + req.headers['content-type'] = 'application/json' + req.environ['nova.context'] = self.context + self.assertRaises(webob.exc.HTTPConflict, + self.controller.delete, + req, + FAKE_UUID, + FAKE_UUID_A) + + @mock.patch.object(compute_api.API, 'detach_volume') + @mock.patch.object(common, 'get_instance') + def test_detach_vol_shelved_supported(self, + mock_get_instance, + mock_detach): + inst = fake_instance.fake_instance_obj(self.context, + **{'uuid': FAKE_UUID}) + inst.vm_state = vm_states.SHELVED + mock_get_instance.return_value = inst + req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments/uuid', version='2.20') + req.method = 'DELETE' + req.headers['content-type'] = 'application/json' + req.environ['nova.context'] = self.context + self.controller.delete(req, FAKE_UUID, FAKE_UUID_A) + self.assertTrue(mock_detach.called) + + def test_detach_vol_not_found(self): + self.stub_out('nova.compute.api.API.detach_volume', + lambda self, context, instance, volume: None) + + self.assertRaises(exc.HTTPNotFound, + self.controller.delete, + self.req, + FAKE_UUID, + FAKE_UUID_C) + + @mock.patch( + 'nova.objects.BlockDeviceMapping.is_root', + new_callable=mock.PropertyMock) + def test_detach_vol_root(self, mock_isroot): + mock_isroot.return_value = True + self.assertRaises(exc.HTTPBadRequest, + self.controller.delete, + self.req, + FAKE_UUID, + FAKE_UUID_A) + + @mock.patch.object(compute_api.API, 'detach_volume') + def test_detach_volume_from_locked_server(self, mock_detach_volume): + mock_detach_volume.side_effect = exception.InstanceIsLocked( + instance_uuid=FAKE_UUID) + self.assertRaises(webob.exc.HTTPConflict, self.controller.delete, + self.req, FAKE_UUID, FAKE_UUID_A) + mock_detach_volume.assert_called_once_with( + self.req.environ['nova.context'], + test.MatchType(objects.Instance), + {'attach_status': 'attached', + 'status': 'in-use', + 'migration_status': None, + 'id': FAKE_UUID_A}) + + @mock.patch.object(compute_api.API, 'detach_volume') + def test_detach_volume_compute_down(self, mock_detach_volume): + mock_detach_volume.side_effect = exception.ServiceUnavailable() + self.assertRaises( + webob.exc.HTTPConflict, self.controller.delete, + self.req, FAKE_UUID, FAKE_UUID_A) + mock_detach_volume.assert_called_once_with( + self.req.environ['nova.context'], + test.MatchType(objects.Instance), + {'attach_status': 'attached', + 'status': 'in-use', + 'id': FAKE_UUID_A, + 'migration_status': None}) + + def test_attach_volume(self): + self.stub_out( + 'nova.compute.api.API.attach_volume', + lambda self, context, instance, volume_id, device, tag=None, + supports_multiattach=False, delete_on_termination=False: None) + body = { + 'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'device': '/dev/fake'}} + result = self.controller.create(self.req, FAKE_UUID, body=body) + self.assertEqual('00000000-aaaa-aaaa-aaaa-000000000000', + result['volumeAttachment']['id']) + + @mock.patch.object(compute_api.API, 'attach_volume', + side_effect=exception.VolumeTaggedAttachNotSupported()) + def test_tagged_volume_attach_not_supported(self, mock_attach_volume): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake'}} + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, + self.req, FAKE_UUID, body=body) + + @mock.patch.object(common, 'get_instance') + def test_attach_vol_shelved_not_supported(self, mock_get_instance): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake'}} + + inst = fake_instance.fake_instance_obj(self.context, + **{'uuid': FAKE_UUID}) + inst.vm_state = vm_states.SHELVED + mock_get_instance.return_value = inst + self.assertRaises(webob.exc.HTTPConflict, + self.controller.create, + self.req, + FAKE_UUID, + body=body) + + @mock.patch.object(compute_api.API, 'attach_volume', + return_value='/dev/myfake') + @mock.patch.object(common, 'get_instance') + def test_attach_vol_shelved_supported(self, + mock_get_instance, + mock_attach): + body = { + 'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'device': '/dev/fake'}} + + inst = fake_instance.fake_instance_obj(self.context, + **{'uuid': FAKE_UUID}) + inst.vm_state = vm_states.SHELVED + mock_get_instance.return_value = inst + req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments', version='2.20') + req.method = 'POST' + req.body = jsonutils.dump_as_bytes({}) + req.headers['content-type'] = 'application/json' + req.environ['nova.context'] = self.context + result = self.controller.create(req, FAKE_UUID, body=body) + self.assertEqual('00000000-aaaa-aaaa-aaaa-000000000000', + result['volumeAttachment']['id']) + self.assertEqual('/dev/myfake', result['volumeAttachment']['device']) + + @mock.patch.object(compute_api.API, 'attach_volume', + return_value='/dev/myfake') + def test_attach_volume_with_auto_device(self, mock_attach): + body = { + 'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'device': None}} + result = self.controller.create(self.req, FAKE_UUID, body=body) + self.assertEqual('00000000-aaaa-aaaa-aaaa-000000000000', + result['volumeAttachment']['id']) + self.assertEqual('/dev/myfake', result['volumeAttachment']['device']) + + @mock.patch.object(compute_api.API, 'attach_volume', + side_effect=exception.InstanceIsLocked( + instance_uuid=uuids.instance)) + def test_attach_volume_to_locked_server(self, mock_attach_volume): + body = { + 'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'device': '/dev/fake'}} + self.assertRaises(webob.exc.HTTPConflict, self.controller.create, + self.req, FAKE_UUID, body=body) + supports_multiattach = api_version_request.is_supported( + self.req, '2.60') + mock_attach_volume.assert_called_once_with( + self.req.environ['nova.context'], + test.MatchType(objects.Instance), FAKE_UUID_A, '/dev/fake', + supports_multiattach=supports_multiattach, + delete_on_termination=False, tag=None) + + def test_attach_volume_bad_id(self): + self.stub_out( + 'nova.compute.api.API.attach_volume', + lambda self, context, instance, volume_id, device, + tag=None, supports_multiattach=False: None) + + body = { + 'volumeAttachment': { + 'device': None, + 'volumeId': 'TESTVOLUME', + } + } + self.assertRaises(self.validation_error, self.controller.create, + self.req, FAKE_UUID, body=body) + + @mock.patch.object(compute_api.API, 'attach_volume', + side_effect=exception.DevicePathInUse(path='/dev/sda')) + def test_attach_volume_device_in_use(self, mock_attach): + + body = { + 'volumeAttachment': { + 'device': '/dev/sda', + 'volumeId': FAKE_UUID_A, + } + } + + self.assertRaises(webob.exc.HTTPConflict, self.controller.create, + self.req, FAKE_UUID, body=body) + + def test_attach_volume_without_volumeId(self): + self.stub_out( + 'nova.compute.api.API.attach_volume', + lambda self, context, instance, volume_id, device, + tag=None, supports_multiattach=False: None + ) + + body = { + 'volumeAttachment': { + 'device': None + } + } + + self.assertRaises(self.validation_error, self.controller.create, + self.req, FAKE_UUID, body=body) + + def test_attach_volume_with_extra_arg(self): + body = { + 'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'device': '/dev/fake', + 'extra': 'extra_arg'}} + + self.assertRaises(self.validation_error, self.controller.create, + self.req, FAKE_UUID, body=body) + + @mock.patch.object(compute_api.API, 'attach_volume') + def test_attach_volume_with_invalid_input(self, mock_attach): + mock_attach.side_effect = exception.InvalidInput( + reason='Invalid volume') + + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake'}} + + req = self._build_request() + req.method = 'POST' + req.body = jsonutils.dump_as_bytes({}) + req.headers['content-type'] = 'application/json' + req.environ['nova.context'] = self.context + + self.assertRaises(exc.HTTPBadRequest, self.controller.create, + req, FAKE_UUID, body=body) + + def _test_swap(self, attachments, uuid=uuids.source_swap_vol, body=None): + body = body or {'volumeAttachment': {'volumeId': FAKE_UUID_B}} + return attachments.update(self.req, uuids.instance, uuid, body=body) + + @mock.patch.object(compute_api.API, 'swap_volume', + side_effect=exception.InstanceIsLocked( + instance_uuid=uuids.instance)) + def test_swap_volume_for_locked_server(self, mock_swap_volume): + with mock.patch.object(self.controller, '_update_volume_regular'): + self.assertRaises(webob.exc.HTTPConflict, self._test_swap, + self.controller) + mock_swap_volume.assert_called_once_with( + self.req.environ['nova.context'], test.MatchType(objects.Instance), + {'attach_status': 'attached', + 'status': 'in-use', + 'id': uuids.source_swap_vol, + 'migration_status': 'migrating'}, + {'attach_status': 'detached', + 'status': 'available', + 'id': FAKE_UUID_B, + 'migration_status': None}) + + @mock.patch.object(compute_api.API, 'swap_volume') + def test_swap_volume(self, mock_swap_volume): + result = self._test_swap(self.controller) + # NOTE: on v2.1, http status code is set as wsgi_codes of API + # method instead of status_int in a response object. + if isinstance(self.controller, + volume_attachments.VolumeAttachmentController): + status_int = self.controller.update.wsgi_codes(self.req) + else: + status_int = result.status_int + self.assertEqual(202, status_int) + mock_swap_volume.assert_called_once_with( + self.req.environ['nova.context'], test.MatchType(objects.Instance), + {'attach_status': 'attached', + 'status': 'in-use', + 'id': uuids.source_swap_vol, + 'migration_status': 'migrating'}, + {'attach_status': 'detached', + 'status': 'available', + 'id': FAKE_UUID_B, + 'migration_status': None}) + + def test_swap_volume_with_nonexistent_uri(self): + self.assertRaises(exc.HTTPNotFound, self._test_swap, + self.controller, uuid=FAKE_UUID_C) + + @mock.patch.object(cinder.API, 'get') + def test_swap_volume_with_nonexistent_dest_in_body(self, mock_get): + mock_get.side_effect = [ + fake_get_volume(None, None, uuids.source_swap_vol), + exception.VolumeNotFound(volume_id=FAKE_UUID_C)] + body = {'volumeAttachment': {'volumeId': FAKE_UUID_C}} + with mock.patch.object(self.controller, '_update_volume_regular'): + self.assertRaises(exc.HTTPBadRequest, self._test_swap, + self.controller, body=body) + mock_get.assert_has_calls([ + mock.call(self.req.environ['nova.context'], uuids.source_swap_vol), + mock.call(self.req.environ['nova.context'], FAKE_UUID_C)]) + + def test_swap_volume_without_volumeId(self): + body = {'volumeAttachment': {'device': '/dev/fake'}} + self.assertRaises(self.validation_error, + self._test_swap, + self.controller, + body=body) + + def test_swap_volume_with_extra_arg(self): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake'}} + + self.assertRaises(self.validation_error, + self._test_swap, + self.controller, + body=body) + + @mock.patch.object(compute_api.API, 'swap_volume', + side_effect=exception.VolumeBDMNotFound( + volume_id=FAKE_UUID_B)) + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance', + side_effect=exception.VolumeBDMNotFound( + volume_id=FAKE_UUID_A)) + def test_swap_volume_for_bdm_not_found(self, mock_bdm, mock_swap_volume): + self.assertRaises(webob.exc.HTTPNotFound, self._test_swap, + self.controller) + if mock_bdm.called: + # New path includes regular PUT procedure + mock_bdm.assert_called_once_with( + self.req.environ['nova.context'], + uuids.source_swap_vol, uuids.instance) + mock_swap_volume.assert_not_called() + else: + # Old path is pure swap-volume + mock_bdm.assert_not_called() + mock_swap_volume.assert_called_once_with( + self.req.environ['nova.context'], + test.MatchType(objects.Instance), + {'attach_status': 'attached', + 'status': 'in-use', + 'migration_status': 'migrating', + 'id': uuids.source_swap_vol}, + {'attach_status': 'detached', + 'status': 'available', + 'id': FAKE_UUID_B, + 'migration_status': None}) + + def _test_list_with_invalid_filter(self, url): + req = self._build_request(url) + self.assertRaises(exception.ValidationError, + self.controller.index, + req, + FAKE_UUID) + + def test_list_with_invalid_non_int_limit(self): + self._test_list_with_invalid_filter('?limit=-9') + + def test_list_with_invalid_string_limit(self): + self._test_list_with_invalid_filter('?limit=abc') + + def test_list_duplicate_query_with_invalid_string_limit(self): + self._test_list_with_invalid_filter( + '?limit=1&limit=abc') + + def test_list_with_invalid_non_int_offset(self): + self._test_list_with_invalid_filter('?offset=-9') + + def test_list_with_invalid_string_offset(self): + self._test_list_with_invalid_filter('?offset=abc') + + def test_list_duplicate_query_with_invalid_string_offset(self): + self._test_list_with_invalid_filter( + '?offset=1&offset=abc') + + @mock.patch.object(objects.BlockDeviceMappingList, + 'get_by_instance_uuid') + def test_list_duplicate_query_parameters_validation(self, mock_get): + fake_bdms = objects.BlockDeviceMappingList() + mock_get.return_value = fake_bdms + params = { + 'limit': 1, + 'offset': 1 + } + for param, value in params.items(): + req = self._build_request('?%s=%s&%s=%s' % ( + param, value, param, value)) + self.controller.index(req, FAKE_UUID) + + @mock.patch.object(objects.BlockDeviceMappingList, + 'get_by_instance_uuid') + def test_list_with_additional_filter(self, mock_get): + fake_bdms = objects.BlockDeviceMappingList() + mock_get.return_value = fake_bdms + req = self._build_request( + '?limit=1&additional=something') + self.controller.index(req, FAKE_UUID) + + +class VolumeAttachTestsV249(test.NoDBTestCase): + validation_error = exception.ValidationError + + def setUp(self): + super().setUp() + self.controller = volume_attachments.VolumeAttachmentController() + self.req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments/uuid', version='2.49') + + def test_tagged_volume_attach_invalid_tag_comma(self): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake', + 'tag': ','}} + self.assertRaises(exception.ValidationError, self.controller.create, + self.req, FAKE_UUID, body=body) + + def test_tagged_volume_attach_invalid_tag_slash(self): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake', + 'tag': '/'}} + self.assertRaises(exception.ValidationError, self.controller.create, + self.req, FAKE_UUID, body=body) + + def test_tagged_volume_attach_invalid_tag_too_long(self): + tag = ''.join(map(str, range(10, 41))) + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake', + 'tag': tag}} + self.assertRaises(exception.ValidationError, self.controller.create, + self.req, FAKE_UUID, body=body) + + @mock.patch('nova.compute.api.API.attach_volume') + @mock.patch('nova.compute.api.API.get', fake_get_instance) + def test_tagged_volume_attach_valid_tag(self, _): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake', + 'tag': 'foo'}} + self.controller.create(self.req, FAKE_UUID, body=body) + + +class VolumeAttachTestsV260(test.NoDBTestCase): + """Negative tests for attaching a multiattach volume with version 2.60.""" + + def setUp(self): + super().setUp() + self.controller = volume_attachments.VolumeAttachmentController() + get_instance = mock.patch('nova.compute.api.API.get') + get_instance.side_effect = fake_get_instance + get_instance.start() + self.addCleanup(get_instance.stop) + + def _post_attach(self, version=None): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A}} + req = fakes.HTTPRequestV21.blank( + '/servers/%s/os-volume_attachments' % FAKE_UUID, + version=version or '2.60') + req.body = jsonutils.dump_as_bytes(body) + req.method = 'POST' + req.headers['content-type'] = 'application/json' + return self.controller.create(req, FAKE_UUID, body=body) + + def test_attach_with_multiattach_fails_old_microversion(self): + """Tests the case that the user tries to attach with a + multiattach volume but before using microversion 2.60. + """ + with mock.patch.object( + self.controller.compute_api, 'attach_volume', + side_effect=exception.MultiattachNotSupportedOldMicroversion + ) as attach: + ex = self.assertRaises(webob.exc.HTTPBadRequest, + self._post_attach, '2.59') + create_kwargs = attach.call_args[1] + self.assertFalse(create_kwargs['supports_multiattach']) + self.assertIn('Multiattach volumes are only supported starting with ' + 'compute API version 2.60', str(ex)) + + def test_attach_with_multiattach_fails_not_supported_by_driver(self): + """Tests the case that the user tries to attach with a + multiattach volume but the compute hosting the instance does + not support multiattach volumes. This would come from + reserve_block_device_name via RPC call to the compute service. + """ + with mock.patch.object( + self.controller.compute_api, 'attach_volume', + side_effect=exception.MultiattachNotSupportedByVirtDriver( + volume_id=FAKE_UUID_A, + ) + ) as attach: + ex = self.assertRaises(webob.exc.HTTPBadRequest, self._post_attach) + create_kwargs = attach.call_args[1] + self.assertTrue(create_kwargs['supports_multiattach']) + self.assertIn("has 'multiattach' set, which is not supported for " + "this instance", str(ex)) + + def test_attach_with_multiattach_fails_for_shelved_offloaded_server(self): + """Tests the case that the user tries to attach with a + multiattach volume to a shelved offloaded server which is + not supported. + """ + with mock.patch.object( + self.controller.compute_api, 'attach_volume', + side_effect=exception.MultiattachToShelvedNotSupported + ) as attach: + ex = self.assertRaises(webob.exc.HTTPBadRequest, self._post_attach) + create_kwargs = attach.call_args[1] + self.assertTrue(create_kwargs['supports_multiattach']) + self.assertIn('Attaching multiattach volumes is not supported for ' + 'shelved-offloaded instances.', str(ex)) + + +class VolumeAttachTestsV275(VolumeAttachTestsV21): + microversion = '2.75' + + def setUp(self): + super().setUp() + self.expected_show = {'volumeAttachment': { + 'device': '/dev/fake0', + 'serverId': FAKE_UUID, + 'id': FAKE_UUID_A, + 'volumeId': FAKE_UUID_A, + 'tag': None, + }} + + @mock.patch.object(objects.BlockDeviceMappingList, + 'get_by_instance_uuid') + def test_list_with_additional_filter_old_version(self, mock_get): + fake_bdms = objects.BlockDeviceMappingList() + mock_get.return_value = fake_bdms + req = fakes.HTTPRequest.blank( + '/os-volumes?limit=1&offset=1&additional=something', + version='2.74') + self.controller.index(req, FAKE_UUID) + + def test_list_with_additional_filter(self): + req = self._build_request( + '?limit=1&additional=something') + self.assertRaises(self.validation_error, self.controller.index, + req, FAKE_UUID) + + +class VolumeAttachTestsV279(VolumeAttachTestsV275): + microversion = '2.79' + + def setUp(self): + super().setUp() + self.controller = volume_attachments.VolumeAttachmentController() + self.expected_show = {'volumeAttachment': { + 'device': '/dev/fake0', + 'serverId': FAKE_UUID, + 'id': FAKE_UUID_A, + 'volumeId': FAKE_UUID_A, + 'tag': None, + 'delete_on_termination': False + }} + + def _get_req(self, body, microversion=None): + req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments/uuid', + version=microversion or self.microversion) + req.body = jsonutils.dump_as_bytes(body) + req.method = 'POST' + req.headers['content-type'] = 'application/json' + return req + + def test_create_volume_attach_pre_v279(self): + """Tests the case that the user tries to attach a volume with + delete_on_termination field, but before using microversion 2.79. + """ + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'delete_on_termination': False}} + req = self._get_req(body, microversion='2.78') + ex = self.assertRaises(exception.ValidationError, + self.controller.create, + req, FAKE_UUID, body=body) + self.assertIn("Additional properties are not allowed", str(ex)) + + @mock.patch('nova.compute.api.API.attach_volume', return_value=None) + def test_attach_volume_pre_v279(self, mock_attach_volume): + """Before microversion 2.79, attach a volume will not contain + 'delete_on_termination' field in the response. + """ + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A}} + req = self._get_req(body, microversion='2.78') + result = self.controller.create(req, FAKE_UUID, body=body) + self.assertNotIn('delete_on_termination', result['volumeAttachment']) + mock_attach_volume.assert_called_once_with( + req.environ['nova.context'], test.MatchType(objects.Instance), + FAKE_UUID_A, None, tag=None, supports_multiattach=True, + delete_on_termination=False) + + @mock.patch('nova.compute.api.API.attach_volume', return_value=None) + def test_attach_volume_with_delete_on_termination_default_value( + self, mock_attach_volume): + """Test attach a volume doesn't specify 'delete_on_termination' in + the request, you will be get it's default value in the response. + The delete_on_termination's default value is 'False'. + """ + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A}} + req = self._get_req(body) + result = self.controller.create(req, FAKE_UUID, body=body) + self.assertFalse(result['volumeAttachment']['delete_on_termination']) + mock_attach_volume.assert_called_once_with( + req.environ['nova.context'], test.MatchType(objects.Instance), + FAKE_UUID_A, None, tag=None, supports_multiattach=True, + delete_on_termination=False) + + def test_create_volume_attach_invalid_delete_on_termination_empty(self): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'delete_on_termination': None}} + req = self._get_req(body) + ex = self.assertRaises(exception.ValidationError, + self.controller.create, + req, FAKE_UUID, body=body) + self.assertIn("Invalid input for field/attribute " + "delete_on_termination.", str(ex)) + + def test_create_volume_attach_invalid_delete_on_termination_value(self): + """"Test the case that the user tries to set the delete_on_termination + value not in the boolean or string-boolean check, the valid boolean + value are: + + [True, 'True', 'TRUE', 'true', '1', 'ON', 'On', 'on', 'YES', 'Yes', + 'yes', False, 'False', 'FALSE', 'false', '0', 'OFF', 'Off', 'off', + 'NO', 'No', 'no'] + """ + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'delete_on_termination': 'foo'}} + req = self._get_req(body) + ex = self.assertRaises(exception.ValidationError, + self.controller.create, + req, FAKE_UUID, body=body) + self.assertIn("Invalid input for field/attribute " + "delete_on_termination.", str(ex)) + + @mock.patch('nova.compute.api.API.attach_volume', return_value=None) + def test_attach_volume_v279(self, mock_attach_volume): + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'delete_on_termination': True}} + req = self._get_req(body) + result = self.controller.create(req, FAKE_UUID, body=body) + self.assertTrue(result['volumeAttachment']['delete_on_termination']) + mock_attach_volume.assert_called_once_with( + req.environ['nova.context'], test.MatchType(objects.Instance), + FAKE_UUID_A, None, tag=None, supports_multiattach=True, + delete_on_termination=True) + + def test_show_pre_v279(self): + """Before microversion 2.79, show a detail of a volume attachment + does not contain the 'delete_on_termination' field in the response + body. + """ + req = self._get_req(body={}, microversion='2.78') + req.method = 'GET' + result = self.controller.show(req, FAKE_UUID, FAKE_UUID_A) + + self.assertNotIn('delete_on_termination', result['volumeAttachment']) + + @mock.patch('nova.objects.BlockDeviceMappingList.get_by_instance_uuid') + def test_list_pre_v279(self, mock_get_bdms): + """Before microversion 2.79, list of a volume attachment + does not contain the 'delete_on_termination' field in the response + body. + """ + req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments', + version="2.78") + req.body = jsonutils.dump_as_bytes({}) + req.method = 'GET' + req.headers['content-type'] = 'application/json' + + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=True, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + bdms = objects.BlockDeviceMappingList(objects=[vol_bdm]) + + mock_get_bdms.return_value = bdms + result = self.controller.index(req, FAKE_UUID) + + self.assertNotIn('delete_on_termination', result['volumeAttachments']) + + +class VolumeAttachTestsV285(VolumeAttachTestsV279): + microversion = '2.85' + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + def test_swap_volume(self, mock_save_bdm, mock_get_bdm): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=uuids.source_swap_vol, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_bdm.return_value = vol_bdm + # On the newer microversion, this test will try to look up the + # BDM to check for update of other fields. + super().test_swap_volume() + + def test_swap_volume_with_extra_arg(self): + # NOTE(danms): Override this from parent because now device + # is checked for unchanged-ness. + body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, + 'device': '/dev/fake0', + 'notathing': 'foo'}} + + self.assertRaises(self.validation_error, + self._test_swap, + self.controller, + body=body) + + @mock.patch.object(compute_api.API, 'swap_volume') + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + def test_update_volume(self, mock_bdm_save, + mock_get_vol_and_inst, mock_swap): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'tag': 'fake-tag', + 'delete_on_termination': True, + 'device': '/dev/fake0', + }} + self.controller.update(self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + mock_swap.assert_not_called() + mock_bdm_save.assert_called_once() + self.assertTrue(vol_bdm['delete_on_termination']) + + @mock.patch.object(compute_api.API, 'swap_volume') + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + def test_update_volume_with_bool_from_string( + self, mock_bdm_save, mock_get_vol_and_inst, mock_swap): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=True, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'tag': 'fake-tag', + 'delete_on_termination': 'False', + 'device': '/dev/fake0', + }} + self.controller.update(self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + mock_swap.assert_not_called() + mock_bdm_save.assert_called_once() + self.assertFalse(vol_bdm['delete_on_termination']) + + # Update delete_on_termination to False + body['volumeAttachment']['delete_on_termination'] = '0' + self.controller.update(self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + mock_swap.assert_not_called() + mock_bdm_save.assert_called() + self.assertFalse(vol_bdm['delete_on_termination']) + + # Update delete_on_termination to True + body['volumeAttachment']['delete_on_termination'] = '1' + self.controller.update(self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + mock_swap.assert_not_called() + mock_bdm_save.assert_called() + self.assertTrue(vol_bdm['delete_on_termination']) + + @mock.patch.object(compute_api.API, 'swap_volume') + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + def test_update_volume_swap(self, mock_bdm_save, + mock_get_vol_and_inst, mock_swap): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=uuids.source_swap_vol, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_B, + 'tag': 'fake-tag', + 'delete_on_termination': True, + }} + self.controller.update(self.req, FAKE_UUID, + uuids.source_swap_vol, body=body) + mock_bdm_save.assert_called_once() + self.assertTrue(vol_bdm['delete_on_termination']) + # Swap volume is tested elsewhere, just make sure that we did + # attempt to call it in addition to updating the BDM + self.assertTrue(mock_swap.called) + + @mock.patch.object(compute_api.API, 'swap_volume') + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + def test_update_volume_swap_only_old_microversion( + self, mock_bdm_save, mock_get_vol_and_inst, mock_swap): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=uuids.source_swap_vol, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_B, + }} + req = self._get_req(body, microversion='2.84') + self.controller.update(req, FAKE_UUID, + uuids.source_swap_vol, body=body) + mock_swap.assert_called_once() + mock_bdm_save.assert_not_called() + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance', + side_effect=exception.VolumeBDMNotFound( + volume_id=FAKE_UUID_A)) + def test_update_volume_with_invalid_volume_id(self, mock_mr): + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'delete_on_termination': True, + }} + self.assertRaises(exc.HTTPNotFound, + self.controller.update, + self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + def test_update_volume_with_changed_attachment_id(self, + mock_get_vol_and_inst): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'id': uuids.attachment_id2, + }} + self.assertRaises(exc.HTTPBadRequest, + self.controller.update, + self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + def test_update_volume_with_changed_attachment_id_old_microversion( + self, mock_get_vol_and_inst, + ): + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'id': uuids.attachment_id, + }} + req = self._get_req(body, microversion='2.84') + ex = self.assertRaises(exception.ValidationError, + self.controller.update, + req, FAKE_UUID, + FAKE_UUID_A, body=body) + self.assertIn('Additional properties are not allowed', str(ex)) + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + def test_update_volume_with_changed_serverId(self, + mock_get_vol_and_inst): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'serverId': uuids.server_id, + }} + self.assertRaises(exc.HTTPBadRequest, + self.controller.update, + self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + def test_update_volume_with_changed_serverId_old_microversion( + self, mock_get_vol_and_inst, + ): + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'serverId': uuids.server_id, + }} + req = self._get_req(body, microversion='2.84') + ex = self.assertRaises(exception.ValidationError, + self.controller.update, + req, FAKE_UUID, + FAKE_UUID_A, body=body) + self.assertIn('Additional properties are not allowed', str(ex)) + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + def test_update_volume_with_changed_device(self, mock_get_vol_and_inst): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'device': '/dev/sdz', + }} + self.assertRaises(exc.HTTPBadRequest, + self.controller.update, + self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + + def test_update_volume_with_device_name_old_microversion(self): + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'device': '/dev/fake0', + }} + req = self._get_req(body, microversion='2.84') + ex = self.assertRaises(exception.ValidationError, + self.controller.update, + req, FAKE_UUID, + FAKE_UUID_A, body=body) + self.assertIn('Additional properties are not allowed', str(ex)) + + @mock.patch.object(objects.BlockDeviceMapping, + 'get_by_volume_and_instance') + def test_update_volume_with_changed_tag(self, mock_get_vol_and_inst): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=False, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + mock_get_vol_and_inst.return_value = vol_bdm + + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'tag': 'icanhaznewtag', + }} + self.assertRaises(exc.HTTPBadRequest, + self.controller.update, + self.req, FAKE_UUID, + FAKE_UUID_A, body=body) + + def test_update_volume_with_tag_old_microversion(self): + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'tag': 'fake-tag', + }} + req = self._get_req(body, microversion='2.84') + ex = self.assertRaises(exception.ValidationError, + self.controller.update, + req, FAKE_UUID, + FAKE_UUID_A, body=body) + self.assertIn('Additional properties are not allowed', str(ex)) + + def test_update_volume_with_delete_flag_old_microversion(self): + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'delete_on_termination': True, + }} + req = self._get_req(body, microversion='2.84') + ex = self.assertRaises(exception.ValidationError, + self.controller.update, + req, FAKE_UUID, + FAKE_UUID_A, body=body) + self.assertIn('Additional properties are not allowed', str(ex)) + + +class VolumeAttachTestsV289(VolumeAttachTestsV285): + microversion = '2.89' + + def setUp(self): + super().setUp() + self.controller = volume_attachments.VolumeAttachmentController() + self.expected_show = { + 'volumeAttachment': { + 'device': '/dev/fake0', + 'serverId': FAKE_UUID, + 'volumeId': FAKE_UUID_A, + 'tag': None, + 'delete_on_termination': False, + 'attachment_id': None, + 'bdm_uuid': uuids.bdm, + } + } + + def test_show_pre_v289(self): + req = self._get_req(body={}, microversion='2.88') + req.method = 'GET' + result = self.controller.show(req, FAKE_UUID, FAKE_UUID_A) + self.assertIn('id', result['volumeAttachment']) + self.assertNotIn('bdm_uuid', result['volumeAttachment']) + self.assertNotIn('attachment_id', result['volumeAttachment']) + + @mock.patch('nova.objects.BlockDeviceMappingList.get_by_instance_uuid') + def test_list(self, mock_get_bdms): + vol_bdm = objects.BlockDeviceMapping( + self.context, + id=1, + uuid=uuids.bdm, + instance_uuid=FAKE_UUID, + volume_id=FAKE_UUID_A, + source_type='volume', + destination_type='volume', + delete_on_termination=True, + connection_info=None, + tag='fake-tag', + device_name='/dev/fake0', + attachment_id=uuids.attachment_id) + bdms = objects.BlockDeviceMappingList(objects=[vol_bdm]) + mock_get_bdms.return_value = bdms + + req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments', + version="2.88") + req.body = jsonutils.dump_as_bytes({}) + req.method = 'GET' + req.headers['content-type'] = 'application/json' + + result = self.controller.index(req, FAKE_UUID) + self.assertIn('id', result['volumeAttachments'][0]) + self.assertNotIn('attachment_id', result['volumeAttachments'][0]) + self.assertNotIn('bdm_uuid', result['volumeAttachments'][0]) + + req = fakes.HTTPRequest.blank( + '/v2.1/servers/id/os-volume_attachments', + version="2.89") + req.body = jsonutils.dump_as_bytes({}) + req.method = 'GET' + req.headers['content-type'] = 'application/json' + + result = self.controller.index(req, FAKE_UUID) + self.assertNotIn('id', result['volumeAttachments'][0]) + self.assertIn('attachment_id', result['volumeAttachments'][0]) + self.assertEqual( + uuids.attachment_id, + result['volumeAttachments'][0]['attachment_id'] + ) + self.assertIn('bdm_uuid', result['volumeAttachments'][0]) + self.assertEqual( + uuids.bdm, + result['volumeAttachments'][0]['bdm_uuid'] + ) + + +class SwapVolumeMultiattachTestCase(test.NoDBTestCase): + + @mock.patch('nova.api.openstack.common.get_instance') + @mock.patch('nova.volume.cinder.API.begin_detaching') + @mock.patch('nova.volume.cinder.API.roll_detaching') + def test_swap_multiattach_multiple_readonly_attachments_fails( + self, mock_roll_detaching, mock_begin_detaching, + mock_get_instance): + """Tests that trying to swap from a multiattach volume with + multiple read/write attachments will return an error. + """ + + def fake_volume_get(_context, volume_id): + if volume_id == uuids.old_vol_id: + return { + 'id': volume_id, + 'size': 1, + 'multiattach': True, + 'migration_status': 'migrating', + 'attachments': { + uuids.server1: { + 'attachment_id': uuids.attachment_id1, + 'mountpoint': '/dev/vdb' + }, + uuids.server2: { + 'attachment_id': uuids.attachment_id2, + 'mountpoint': '/dev/vdb' + } + } + } + if volume_id == uuids.new_vol_id: + return { + 'id': volume_id, + 'size': 1, + 'attach_status': 'detached' + } + raise exception.VolumeNotFound(volume_id=volume_id) + + def fake_attachment_get(_context, attachment_id): + return {'attach_mode': 'rw'} + + ctxt = context.get_admin_context() + instance = fake_instance.fake_instance_obj( + ctxt, uuid=uuids.server1, vm_state=vm_states.ACTIVE, + task_state=None, launched_at=datetime.datetime(2018, 6, 6)) + mock_get_instance.return_value = instance + controller = volume_attachments.VolumeAttachmentController() + with test.nested( + mock.patch.object(controller.volume_api, 'get', + side_effect=fake_volume_get), + mock.patch.object(controller.compute_api.volume_api, + 'attachment_get', + side_effect=fake_attachment_get)) as ( + mock_volume_get, mock_attachment_get + ): + req = fakes.HTTPRequest.blank( + '/servers/%s/os-volume_attachments/%s' % + (uuids.server1, uuids.old_vol_id)) + req.headers['content-type'] = 'application/json' + req.environ['nova.context'] = ctxt + body = { + 'volumeAttachment': { + 'volumeId': uuids.new_vol_id + } + } + ex = self.assertRaises( + webob.exc.HTTPBadRequest, controller.update, req, + uuids.server1, uuids.old_vol_id, body=body) + self.assertIn( + 'Swapping multi-attach volumes with more than one ', str(ex)) + mock_attachment_get.assert_has_calls([ + mock.call(ctxt, uuids.attachment_id1), + mock.call(ctxt, uuids.attachment_id2)], any_order=True) + mock_roll_detaching.assert_called_once_with(ctxt, uuids.old_vol_id) diff --git a/nova/tests/unit/api/openstack/compute/test_volumes.py b/nova/tests/unit/api/openstack/compute/test_volumes.py index d9d31014bbd7..974884fc23e8 100644 --- a/nova/tests/unit/api/openstack/compute/test_volumes.py +++ b/nova/tests/unit/api/openstack/compute/test_volumes.py @@ -15,126 +15,30 @@ # under the License. import datetime -import urllib - from unittest import mock -import fixtures +from oslo_serialization import jsonutils +from oslo_utils.fixture import uuidsentinel as uuids import webob -from oslo_serialization import jsonutils -from oslo_utils import encodeutils -from oslo_utils.fixture import uuidsentinel as uuids -from webob import exc - -from nova.api.openstack import api_version_request -from nova.api.openstack import common -from nova.api.openstack.compute import assisted_volume_snapshots \ - as assisted_snaps from nova.api.openstack.compute import volumes as volumes_v21 -from nova.compute import api as compute_api from nova.compute import flavors -from nova.compute import task_states -from nova.compute import vm_states import nova.conf -from nova import context from nova import exception -from nova import objects -from nova.objects import block_device as block_device_obj from nova import test from nova.tests.unit.api.openstack import fakes -from nova.tests.unit import fake_block_device -from nova.tests.unit import fake_instance from nova.volume import cinder - CONF = nova.conf.CONF -# This is the server ID. FAKE_UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -# This is the old volume ID (to swap from). -FAKE_UUID_A = '00000000-aaaa-aaaa-aaaa-000000000000' -# This is the new volume ID (to swap to). -FAKE_UUID_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' -# This is a volume that is not found. -FAKE_UUID_C = 'cccccccc-cccc-cccc-cccc-cccccccccccc' - IMAGE_UUID = 'c905cedb-7281-47e4-8a62-f26bc5fc4c77' -def fake_get_instance(self, context, instance_id, expected_attrs=None, - cell_down_support=False): - return fake_instance.fake_instance_obj( - context, id=1, uuid=instance_id, project_id=context.project_id) - -# TODO(sean-k-mooney): this is duplicated in the policy tests -# we should consider consolidating this. - - -def fake_get_volume(self, context, id): - migration_status = None - if id == FAKE_UUID_A: - status = 'in-use' - attach_status = 'attached' - elif id == FAKE_UUID_B: - status = 'available' - attach_status = 'detached' - elif id == uuids.source_swap_vol: - status = 'in-use' - attach_status = 'attached' - migration_status = 'migrating' - else: - raise exception.VolumeNotFound(volume_id=id) - return { - 'id': id, 'status': status, 'attach_status': attach_status, - 'migration_status': migration_status - } - - -def fake_create_snapshot(self, context, volume, name, description): - return {'id': 123, - 'volume_id': 'fakeVolId', - 'status': 'available', - 'volume_size': 123, - 'created_at': '2013-01-01 00:00:01', - 'display_name': 'myVolumeName', - 'display_description': 'myVolumeDescription'} - - -def fake_delete_snapshot(self, context, snapshot_id): - pass - - -def fake_compute_volume_snapshot_delete(self, context, volume_id, snapshot_id, - delete_info): - pass - - -@classmethod -def fake_bdm_get_by_volume_and_instance(cls, ctxt, volume_id, instance_uuid): - if volume_id not in (FAKE_UUID_A, uuids.source_swap_vol): - raise exception.VolumeBDMNotFound(volume_id=volume_id) - db_bdm = fake_block_device.FakeDbBlockDeviceDict({ - 'id': 1, - 'uuid': uuids.bdm, - 'instance_uuid': instance_uuid, - 'device_name': '/dev/fake0', - 'delete_on_termination': 'False', - 'source_type': 'volume', - 'destination_type': 'volume', - 'snapshot_id': None, - 'volume_id': volume_id, - 'volume_size': 1, - 'attachment_id': uuids.attachment_id - }) - return objects.BlockDeviceMapping._from_db_object( - ctxt, objects.BlockDeviceMapping(), db_bdm) - - class BootFromVolumeTest(test.TestCase): def setUp(self): - super(BootFromVolumeTest, self).setUp() + super().setUp() self.stub_out('nova.compute.api.API.create', self._get_fake_compute_api_create()) fakes.stub_out_nw_api(self) @@ -225,47 +129,35 @@ class BootFromVolumeTest(test.TestCase): class VolumeApiTestV21(test.NoDBTestCase): def setUp(self): - super(VolumeApiTestV21, self).setUp() + super().setUp() fakes.stub_out_networking(self) + self.stub_out('nova.volume.cinder.API.create', + fakes.stub_volume_create) self.stub_out('nova.volume.cinder.API.delete', lambda self, context, volume_id: None) self.stub_out('nova.volume.cinder.API.get', fakes.stub_volume_get) self.stub_out('nova.volume.cinder.API.get_all', fakes.stub_volume_get_all) - self.context = context.get_admin_context() - - @property - def app(self): - return fakes.wsgi_app_v21() + self.controller = volumes_v21.VolumeController() + self.req = fakes.HTTPRequest.blank('') def test_volume_create(self): - self.stub_out('nova.volume.cinder.API.create', - fakes.stub_volume_create) - vol = {"size": 100, "display_name": "Volume Test Name", "display_description": "Volume Test Desc", "availability_zone": "zone1:host1"} body = {"volume": vol} - req = fakes.HTTPRequest.blank('/v2.1/os-volumes') - req.method = 'POST' - req.body = jsonutils.dump_as_bytes(body) - req.headers['content-type'] = 'application/json' - resp = req.get_response(self.app) + resp = self.controller.create(self.req, body=body).obj - self.assertEqual(200, resp.status_int) - - resp_dict = jsonutils.loads(resp.body) - self.assertIn('volume', resp_dict) - self.assertEqual(vol['size'], resp_dict['volume']['size']) - self.assertEqual(vol['display_name'], - resp_dict['volume']['displayName']) - self.assertEqual(vol['display_description'], - resp_dict['volume']['displayDescription']) - self.assertEqual(vol['availability_zone'], - resp_dict['volume']['availabilityZone']) + self.assertIn('volume', resp) + self.assertEqual(vol['size'], resp['volume']['size']) + self.assertEqual(vol['display_name'], resp['volume']['displayName']) + self.assertEqual( + vol['display_description'], resp['volume']['displayDescription']) + self.assertEqual( + vol['availability_zone'], resp['volume']['availabilityZone']) @mock.patch.object(cinder.API, 'create') def _test_volume_translate_exception(self, cinder_exc, api_exc, @@ -279,12 +171,11 @@ class VolumeApiTestV21(test.NoDBTestCase): "availability_zone": "zone1:host1"} body = {"volume": vol} - req = fakes.HTTPRequest.blank('/v2.1/os-volumes') self.assertRaises(api_exc, - volumes_v21.VolumeController().create, req, + self.controller.create, self.req, body=body) mock_create.assert_called_once_with( - req.environ['nova.context'], '10', 'Volume Test Name', + mock.ANY, '10', 'Volume Test Name', 'Volume Test Desc', availability_zone='zone1:host1', metadata=None, snapshot=None, volume_type=None) @@ -295,9 +186,8 @@ class VolumeApiTestV21(test.NoDBTestCase): body = {"volume": vol} mock_get.side_effect = exception.SnapshotNotFound(snapshot_id='1') - req = fakes.HTTPRequest.blank('/v2.1/os-volumes') self.assertRaises(webob.exc.HTTPNotFound, - volumes_v21.VolumeController().create, req, + self.controller.create, self.req, body=body) def test_volume_create_bad_input(self): @@ -308,58 +198,61 @@ class VolumeApiTestV21(test.NoDBTestCase): self._test_volume_translate_exception( exception.OverQuota(overs='fake'), webob.exc.HTTPForbidden) + def _bad_request_create(self, body): + req = fakes.HTTPRequest.blank( + '/v2/%s/os-volumes' % (fakes.FAKE_PROJECT_ID)) + req.method = 'POST' + + self.assertRaises(exception.ValidationError, + self.controller.create, req, body=body) + + def test_volume_create_no_body(self): + self._bad_request_create(body=None) + + def test_volume_create_missing_volume(self): + body = {'foo': {'a': 'b'}} + self._bad_request_create(body=body) + + def test_volume_create_malformed_entity(self): + body = {'volume': 'string'} + self._bad_request_create(body=body) + def test_volume_index(self): - req = fakes.HTTPRequest.blank('/v2.1/os-volumes') - resp = req.get_response(self.app) - self.assertEqual(200, resp.status_int) + self.controller.index(self.req) def test_volume_detail(self): - req = fakes.HTTPRequest.blank('/v2.1/os-volumes/detail') - resp = req.get_response(self.app) - self.assertEqual(200, resp.status_int) + self.controller.detail(self.req) def test_volume_show(self): - req = fakes.HTTPRequest.blank('/v2.1/os-volumes/123') - resp = req.get_response(self.app) - self.assertEqual(200, resp.status_int) + self.controller.show(self.req, uuids.volume) @mock.patch.object(cinder.API, 'get', side_effect=exception.VolumeNotFound( volume_id=uuids.volume)) def test_volume_show_no_volume(self, mock_get): - req = fakes.HTTPRequest.blank('/v2.1/os-volumes/%s' % uuids.volume) - resp = req.get_response(self.app) - self.assertEqual(404, resp.status_int) - self.assertIn('Volume %s could not be found.' % uuids.volume, - encodeutils.safe_decode(resp.body)) - mock_get.assert_called_once_with(req.environ['nova.context'], - uuids.volume) + exc = self.assertRaises( + webob.exc.HTTPNotFound, self.controller.show, + self.req, uuids.volume) + self.assertIn('Volume %s could not be found.' % uuids.volume, str(exc)) + mock_get.assert_called_once() def test_volume_delete(self): - req = fakes.HTTPRequest.blank('/v2.1/os-volumes/123') - req.method = 'DELETE' - resp = req.get_response(self.app) - self.assertEqual(202, resp.status_int) + self.controller.delete(self.req, uuids.volume) @mock.patch.object(cinder.API, 'delete', side_effect=exception.VolumeNotFound( volume_id=uuids.volume)) def test_volume_delete_no_volume(self, mock_delete): - req = fakes.HTTPRequest.blank('/v2.1/os-volumes/%s' % uuids.volume) - req.method = 'DELETE' - resp = req.get_response(self.app) - self.assertEqual(404, resp.status_int) - self.assertIn('Volume %s could not be found.' % uuids.volume, - encodeutils.safe_decode(resp.body)) - mock_delete.assert_called_once_with(req.environ['nova.context'], - uuids.volume) + exc = self.assertRaises( + webob.exc.HTTPNotFound, self.controller.delete, + self.req, uuids.volume) + self.assertIn('Volume %s could not be found.' % uuids.volume, str(exc)) + mock_delete.assert_called_once() def _test_list_with_invalid_filter(self, url): - prefix = '/os-volumes' - req = fakes.HTTPRequest.blank(prefix + url) - self.assertRaises(exception.ValidationError, - volumes_v21.VolumeController().index, - req) + req = fakes.HTTPRequest.blank('/os-volumes' + url) + self.assertRaises( + exception.ValidationError, self.controller.index, req) def test_list_with_invalid_non_int_limit(self): self._test_list_with_invalid_filter('?limit=-9') @@ -368,18 +261,7 @@ class VolumeApiTestV21(test.NoDBTestCase): self._test_list_with_invalid_filter('?limit=abc') def test_list_duplicate_query_with_invalid_string_limit(self): - self._test_list_with_invalid_filter( - '?limit=1&limit=abc') - - def test_detail_list_with_invalid_non_int_limit(self): - self._test_list_with_invalid_filter('/detail?limit=-9') - - def test_detail_list_with_invalid_string_limit(self): - self._test_list_with_invalid_filter('/detail?limit=abc') - - def test_detail_list_duplicate_query_with_invalid_string_limit(self): - self._test_list_with_invalid_filter( - '/detail?limit=1&limit=abc') + self._test_list_with_invalid_filter('?limit=1&limit=abc') def test_list_with_invalid_non_int_offset(self): self._test_list_with_invalid_filter('?offset=-9') @@ -388,1616 +270,66 @@ class VolumeApiTestV21(test.NoDBTestCase): self._test_list_with_invalid_filter('?offset=abc') def test_list_duplicate_query_with_invalid_string_offset(self): - self._test_list_with_invalid_filter( - '?offset=1&offset=abc') + self._test_list_with_invalid_filter('?offset=1&offset=abc') + + def _test_list_detail_with_invalid_filter(self, url): + req = fakes.HTTPRequest.blank('/os-volumes/detail' + url) + self.assertRaises( + exception.ValidationError, self.controller.detail, req) + + def test_detail_list_with_invalid_non_int_limit(self): + self._test_list_detail_with_invalid_filter('?limit=-9') + + def test_detail_list_with_invalid_string_limit(self): + self._test_list_detail_with_invalid_filter('?limit=abc') + + def test_detail_list_duplicate_query_with_invalid_string_limit(self): + self._test_list_detail_with_invalid_filter('?limit=1&limit=abc') def test_detail_list_with_invalid_non_int_offset(self): - self._test_list_with_invalid_filter('/detail?offset=-9') + self._test_list_detail_with_invalid_filter('?offset=-9') def test_detail_list_with_invalid_string_offset(self): - self._test_list_with_invalid_filter('/detail?offset=abc') + self._test_list_detail_with_invalid_filter('?offset=abc') def test_detail_list_duplicate_query_with_invalid_string_offset(self): - self._test_list_with_invalid_filter( - '/detail?offset=1&offset=abc') + self._test_list_detail_with_invalid_filter('?offset=1&offset=abc') def _test_list_duplicate_query_parameters_validation(self, url): - params = { - 'limit': 1, - 'offset': 1 - } + params = {'limit': 1, 'offset': 1} for param, value in params.items(): req = fakes.HTTPRequest.blank( url + '?%s=%s&%s=%s' % (param, value, param, value)) - resp = req.get_response(self.app) - self.assertEqual(200, resp.status_int) + self.controller.index(req) def test_list_duplicate_query_parameters_validation(self): - self._test_list_duplicate_query_parameters_validation( - '/v2.1/os-volumes') + params = {'limit': 1, 'offset': 1} + for p, v in params.items(): + req = fakes.HTTPRequest.blank(f'/os-volumes?{p}={v}&{p}={v}') + self.controller.index(req) def test_detail_list_duplicate_query_parameters_validation(self): - self._test_list_duplicate_query_parameters_validation( - '/v2.1/os-volumes/detail') + params = {'limit': 1, 'offset': 1} + for p, v in params.items(): + req = fakes.HTTPRequest.blank( + f'/os-volumes/detail?{p}={v}&{p}={v}') + self.controller.detail(req) def test_list_with_additional_filter(self): req = fakes.HTTPRequest.blank( - '/v2.1/os-volumes?limit=1&offset=1&additional=something') - resp = req.get_response(self.app) - self.assertEqual(200, resp.status_int) + '/os-volumes?limit=1&offset=1&additional=something') + self.controller.index(req) def test_detail_list_with_additional_filter(self): req = fakes.HTTPRequest.blank( - '/v2.1/os-volumes/detail?limit=1&offset=1&additional=something') - resp = req.get_response(self.app) - self.assertEqual(200, resp.status_int) - - -class VolumeAttachTestsV21(test.NoDBTestCase): - validation_error = exception.ValidationError - microversion = '2.1' - _prefix = '/servers/id/os-volume_attachments' - - def setUp(self): - super(VolumeAttachTestsV21, self).setUp() - self.stub_out('nova.objects.BlockDeviceMapping' - '.get_by_volume_and_instance', - fake_bdm_get_by_volume_and_instance) - self.stub_out('nova.compute.api.API.get', fake_get_instance) - self.stub_out('nova.volume.cinder.API.get', fake_get_volume) - self.context = context.get_admin_context() - self.expected_show = {'volumeAttachment': - {'device': '/dev/fake0', - 'serverId': FAKE_UUID, - 'id': FAKE_UUID_A, - 'volumeId': FAKE_UUID_A - }} - self.attachments = volumes_v21.VolumeAttachmentController() - - self.req = self._build_request('/uuid') - self.req.body = jsonutils.dump_as_bytes({}) - self.req.headers['content-type'] = 'application/json' - self.req.environ['nova.context'] = self.context - - def _build_request(self, url=''): - return fakes.HTTPRequest.blank( - self._prefix + url, version=self.microversion) - - def test_show(self): - result = self.attachments.show(self.req, FAKE_UUID, FAKE_UUID_A) - self.assertEqual(self.expected_show, result) - - @mock.patch.object(compute_api.API, 'get', - side_effect=exception.InstanceNotFound(instance_id=FAKE_UUID)) - def test_show_no_instance(self, mock_mr): - self.assertRaises(exc.HTTPNotFound, - self.attachments.show, - self.req, - FAKE_UUID, - FAKE_UUID_A) - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance', - side_effect=exception.VolumeBDMNotFound( - volume_id=FAKE_UUID_A)) - def test_show_no_bdms(self, mock_mr): - self.assertRaises(exc.HTTPNotFound, - self.attachments.show, - self.req, - FAKE_UUID, - FAKE_UUID_A) - - def test_show_bdms_no_mountpoint(self): - FAKE_UUID_NOTEXIST = '00000000-aaaa-aaaa-aaaa-aaaaaaaaaaaa' - - self.assertRaises(exc.HTTPNotFound, - self.attachments.show, - self.req, - FAKE_UUID, - FAKE_UUID_NOTEXIST) - - def test_detach(self): - self.stub_out('nova.compute.api.API.detach_volume', - lambda self, context, instance, volume: None) - inst = fake_instance.fake_instance_obj(self.context, - **{'uuid': FAKE_UUID}) - with mock.patch.object(common, 'get_instance', - return_value=inst) as mock_get_instance: - result = self.attachments.delete(self.req, FAKE_UUID, FAKE_UUID_A) - # NOTE: on v2.1, http status code is set as wsgi_codes of API - # method instead of status_int in a response object. - if isinstance(self.attachments, - volumes_v21.VolumeAttachmentController): - status_int = self.attachments.delete.wsgi_codes(self.req) - else: - status_int = result.status_int - self.assertEqual(202, status_int) - mock_get_instance.assert_called_with( - self.attachments.compute_api, self.context, FAKE_UUID, - expected_attrs=['device_metadata']) - - @mock.patch.object(common, 'get_instance') - def test_detach_vol_shelved_not_supported(self, mock_get_instance): - inst = fake_instance.fake_instance_obj(self.context, - **{'uuid': FAKE_UUID}) - inst.vm_state = vm_states.SHELVED - mock_get_instance.return_value = inst - req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments/uuid', version='2.19') - req.method = 'DELETE' - req.headers['content-type'] = 'application/json' - req.environ['nova.context'] = self.context - self.assertRaises(webob.exc.HTTPConflict, - self.attachments.delete, - req, - FAKE_UUID, - FAKE_UUID_A) - - @mock.patch.object(compute_api.API, 'detach_volume') - @mock.patch.object(common, 'get_instance') - def test_detach_vol_shelved_supported(self, - mock_get_instance, - mock_detach): - inst = fake_instance.fake_instance_obj(self.context, - **{'uuid': FAKE_UUID}) - inst.vm_state = vm_states.SHELVED - mock_get_instance.return_value = inst - req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments/uuid', version='2.20') - req.method = 'DELETE' - req.headers['content-type'] = 'application/json' - req.environ['nova.context'] = self.context - self.attachments.delete(req, FAKE_UUID, FAKE_UUID_A) - self.assertTrue(mock_detach.called) - - def test_detach_vol_not_found(self): - self.stub_out('nova.compute.api.API.detach_volume', - lambda self, context, instance, volume: None) - - self.assertRaises(exc.HTTPNotFound, - self.attachments.delete, - self.req, - FAKE_UUID, - FAKE_UUID_C) - - @mock.patch('nova.objects.BlockDeviceMapping.is_root', - new_callable=mock.PropertyMock) - def test_detach_vol_root(self, mock_isroot): - mock_isroot.return_value = True - self.assertRaises(exc.HTTPBadRequest, - self.attachments.delete, - self.req, - FAKE_UUID, - FAKE_UUID_A) - - @mock.patch.object(compute_api.API, 'detach_volume') - def test_detach_volume_from_locked_server(self, mock_detach_volume): - mock_detach_volume.side_effect = exception.InstanceIsLocked( - instance_uuid=FAKE_UUID) - self.assertRaises(webob.exc.HTTPConflict, self.attachments.delete, - self.req, FAKE_UUID, FAKE_UUID_A) - mock_detach_volume.assert_called_once_with( - self.req.environ['nova.context'], - test.MatchType(objects.Instance), - {'attach_status': 'attached', - 'status': 'in-use', - 'migration_status': None, - 'id': FAKE_UUID_A}) - - @mock.patch.object(compute_api.API, 'detach_volume') - def test_detach_volume_compute_down(self, mock_detach_volume): - mock_detach_volume.side_effect = exception.ServiceUnavailable() - self.assertRaises( - webob.exc.HTTPConflict, self.attachments.delete, - self.req, FAKE_UUID, FAKE_UUID_A) - mock_detach_volume.assert_called_once_with( - self.req.environ['nova.context'], - test.MatchType(objects.Instance), - {'attach_status': 'attached', - 'status': 'in-use', - 'id': FAKE_UUID_A, - 'migration_status': None}) - - def test_attach_volume(self): - self.stub_out('nova.compute.api.API.attach_volume', - lambda self, context, instance, volume_id, - device, tag=None, - supports_multiattach=False, - delete_on_termination=False: None) - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake'}} - result = self.attachments.create(self.req, FAKE_UUID, body=body) - self.assertEqual('00000000-aaaa-aaaa-aaaa-000000000000', - result['volumeAttachment']['id']) - - @mock.patch.object(compute_api.API, 'attach_volume', - side_effect=exception.VolumeTaggedAttachNotSupported()) - def test_tagged_volume_attach_not_supported(self, mock_attach_volume): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake'}} - self.assertRaises(webob.exc.HTTPBadRequest, self.attachments.create, - self.req, FAKE_UUID, body=body) - - @mock.patch.object(common, 'get_instance') - def test_attach_vol_shelved_not_supported(self, mock_get_instance): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake'}} - - inst = fake_instance.fake_instance_obj(self.context, - **{'uuid': FAKE_UUID}) - inst.vm_state = vm_states.SHELVED - mock_get_instance.return_value = inst - self.assertRaises(webob.exc.HTTPConflict, - self.attachments.create, - self.req, - FAKE_UUID, - body=body) - - @mock.patch.object(compute_api.API, 'attach_volume', - return_value='/dev/myfake') - @mock.patch.object(common, 'get_instance') - def test_attach_vol_shelved_supported(self, - mock_get_instance, - mock_attach): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake'}} - - inst = fake_instance.fake_instance_obj(self.context, - **{'uuid': FAKE_UUID}) - inst.vm_state = vm_states.SHELVED - mock_get_instance.return_value = inst - req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments', version='2.20') - req.method = 'POST' - req.body = jsonutils.dump_as_bytes({}) - req.headers['content-type'] = 'application/json' - req.environ['nova.context'] = self.context - result = self.attachments.create(req, FAKE_UUID, body=body) - self.assertEqual('00000000-aaaa-aaaa-aaaa-000000000000', - result['volumeAttachment']['id']) - self.assertEqual('/dev/myfake', result['volumeAttachment']['device']) - - @mock.patch.object(compute_api.API, 'attach_volume', - return_value='/dev/myfake') - def test_attach_volume_with_auto_device(self, mock_attach): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': None}} - result = self.attachments.create(self.req, FAKE_UUID, body=body) - self.assertEqual('00000000-aaaa-aaaa-aaaa-000000000000', - result['volumeAttachment']['id']) - self.assertEqual('/dev/myfake', result['volumeAttachment']['device']) - - @mock.patch.object(compute_api.API, 'attach_volume', - side_effect=exception.InstanceIsLocked( - instance_uuid=uuids.instance)) - def test_attach_volume_to_locked_server(self, mock_attach_volume): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake'}} - self.assertRaises(webob.exc.HTTPConflict, self.attachments.create, - self.req, FAKE_UUID, body=body) - supports_multiattach = api_version_request.is_supported( - self.req, '2.60') - mock_attach_volume.assert_called_once_with( - self.req.environ['nova.context'], - test.MatchType(objects.Instance), FAKE_UUID_A, '/dev/fake', - supports_multiattach=supports_multiattach, - delete_on_termination=False, tag=None) - - def test_attach_volume_bad_id(self): - self.stub_out('nova.compute.api.API.attach_volume', - lambda self, context, instance, volume_id, device, - tag=None, supports_multiattach=False: None) - - body = { - 'volumeAttachment': { - 'device': None, - 'volumeId': 'TESTVOLUME', - } - } - self.assertRaises(self.validation_error, self.attachments.create, - self.req, FAKE_UUID, body=body) - - @mock.patch.object(compute_api.API, 'attach_volume', - side_effect=exception.DevicePathInUse(path='/dev/sda')) - def test_attach_volume_device_in_use(self, mock_attach): - - body = { - 'volumeAttachment': { - 'device': '/dev/sda', - 'volumeId': FAKE_UUID_A, - } - } - - self.assertRaises(webob.exc.HTTPConflict, self.attachments.create, - self.req, FAKE_UUID, body=body) - - def test_attach_volume_without_volumeId(self): - self.stub_out('nova.compute.api.API.attach_volume', - lambda self, context, instance, volume_id, device, - tag=None, supports_multiattach=False: None) - - body = { - 'volumeAttachment': { - 'device': None - } - } - - self.assertRaises(self.validation_error, self.attachments.create, - self.req, FAKE_UUID, body=body) - - def test_attach_volume_with_extra_arg(self): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake', - 'extra': 'extra_arg'}} - - self.assertRaises(self.validation_error, self.attachments.create, - self.req, FAKE_UUID, body=body) - - @mock.patch.object(compute_api.API, 'attach_volume') - def test_attach_volume_with_invalid_input(self, mock_attach): - mock_attach.side_effect = exception.InvalidInput( - reason='Invalid volume') - - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake'}} - - req = self._build_request() - req.method = 'POST' - req.body = jsonutils.dump_as_bytes({}) - req.headers['content-type'] = 'application/json' - req.environ['nova.context'] = self.context - - self.assertRaises(exc.HTTPBadRequest, self.attachments.create, - req, FAKE_UUID, body=body) - - def _test_swap(self, attachments, uuid=uuids.source_swap_vol, body=None): - body = body or {'volumeAttachment': {'volumeId': FAKE_UUID_B}} - return attachments.update(self.req, uuids.instance, uuid, body=body) - - @mock.patch.object(compute_api.API, 'swap_volume', - side_effect=exception.InstanceIsLocked( - instance_uuid=uuids.instance)) - def test_swap_volume_for_locked_server(self, mock_swap_volume): - with mock.patch.object(self.attachments, '_update_volume_regular'): - self.assertRaises(webob.exc.HTTPConflict, self._test_swap, - self.attachments) - mock_swap_volume.assert_called_once_with( - self.req.environ['nova.context'], test.MatchType(objects.Instance), - {'attach_status': 'attached', - 'status': 'in-use', - 'id': uuids.source_swap_vol, - 'migration_status': 'migrating' - }, - {'attach_status': 'detached', - 'status': 'available', - 'id': FAKE_UUID_B, - 'migration_status': None}) - - @mock.patch.object(compute_api.API, 'swap_volume') - def test_swap_volume(self, mock_swap_volume): - result = self._test_swap(self.attachments) - # NOTE: on v2.1, http status code is set as wsgi_codes of API - # method instead of status_int in a response object. - if isinstance(self.attachments, - volumes_v21.VolumeAttachmentController): - status_int = self.attachments.update.wsgi_codes(self.req) - else: - status_int = result.status_int - self.assertEqual(202, status_int) - mock_swap_volume.assert_called_once_with( - self.req.environ['nova.context'], test.MatchType(objects.Instance), - {'attach_status': 'attached', - 'status': 'in-use', - 'id': uuids.source_swap_vol, - 'migration_status': 'migrating'}, - {'attach_status': 'detached', - 'status': 'available', - 'id': FAKE_UUID_B, - 'migration_status': None}) - - def test_swap_volume_with_nonexistent_uri(self): - self.assertRaises(exc.HTTPNotFound, self._test_swap, - self.attachments, uuid=FAKE_UUID_C) - - @mock.patch.object(cinder.API, 'get') - def test_swap_volume_with_nonexistent_dest_in_body(self, mock_get): - mock_get.side_effect = [ - fake_get_volume(None, None, uuids.source_swap_vol), - exception.VolumeNotFound(volume_id=FAKE_UUID_C)] - body = {'volumeAttachment': {'volumeId': FAKE_UUID_C}} - with mock.patch.object(self.attachments, '_update_volume_regular'): - self.assertRaises(exc.HTTPBadRequest, self._test_swap, - self.attachments, body=body) - mock_get.assert_has_calls([ - mock.call(self.req.environ['nova.context'], uuids.source_swap_vol), - mock.call(self.req.environ['nova.context'], FAKE_UUID_C)]) - - def test_swap_volume_without_volumeId(self): - body = {'volumeAttachment': {'device': '/dev/fake'}} - self.assertRaises(self.validation_error, - self._test_swap, - self.attachments, - body=body) - - def test_swap_volume_with_extra_arg(self): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake'}} - - self.assertRaises(self.validation_error, - self._test_swap, - self.attachments, - body=body) - - @mock.patch.object(compute_api.API, 'swap_volume', - side_effect=exception.VolumeBDMNotFound( - volume_id=FAKE_UUID_B)) - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance', - side_effect=exception.VolumeBDMNotFound( - volume_id=FAKE_UUID_A)) - def test_swap_volume_for_bdm_not_found(self, mock_bdm, mock_swap_volume): - self.assertRaises(webob.exc.HTTPNotFound, self._test_swap, - self.attachments) - if mock_bdm.called: - # New path includes regular PUT procedure - mock_bdm.assert_called_once_with( - self.req.environ['nova.context'], - uuids.source_swap_vol, uuids.instance) - mock_swap_volume.assert_not_called() - else: - # Old path is pure swap-volume - mock_bdm.assert_not_called() - mock_swap_volume.assert_called_once_with( - self.req.environ['nova.context'], - test.MatchType(objects.Instance), - {'attach_status': 'attached', - 'status': 'in-use', - 'migration_status': 'migrating', - 'id': uuids.source_swap_vol}, - {'attach_status': 'detached', - 'status': 'available', - 'id': FAKE_UUID_B, - 'migration_status': None}) - - def _test_list_with_invalid_filter(self, url): - req = self._build_request(url) - self.assertRaises(exception.ValidationError, - self.attachments.index, - req, - FAKE_UUID) - - def test_list_with_invalid_non_int_limit(self): - self._test_list_with_invalid_filter('?limit=-9') - - def test_list_with_invalid_string_limit(self): - self._test_list_with_invalid_filter('?limit=abc') - - def test_list_duplicate_query_with_invalid_string_limit(self): - self._test_list_with_invalid_filter( - '?limit=1&limit=abc') - - def test_list_with_invalid_non_int_offset(self): - self._test_list_with_invalid_filter('?offset=-9') - - def test_list_with_invalid_string_offset(self): - self._test_list_with_invalid_filter('?offset=abc') - - def test_list_duplicate_query_with_invalid_string_offset(self): - self._test_list_with_invalid_filter( - '?offset=1&offset=abc') - - @mock.patch.object(objects.BlockDeviceMappingList, - 'get_by_instance_uuid') - def test_list_duplicate_query_parameters_validation(self, mock_get): - fake_bdms = objects.BlockDeviceMappingList() - mock_get.return_value = fake_bdms - params = { - 'limit': 1, - 'offset': 1 - } - for param, value in params.items(): - req = self._build_request('?%s=%s&%s=%s' % - (param, value, param, value)) - self.attachments.index(req, FAKE_UUID) - - @mock.patch.object(objects.BlockDeviceMappingList, - 'get_by_instance_uuid') - def test_list_with_additional_filter(self, mock_get): - fake_bdms = objects.BlockDeviceMappingList() - mock_get.return_value = fake_bdms - req = self._build_request( - '?limit=1&additional=something') - self.attachments.index(req, FAKE_UUID) - - -class VolumeAttachTestsV249(test.NoDBTestCase): - validation_error = exception.ValidationError - - def setUp(self): - super(VolumeAttachTestsV249, self).setUp() - self.attachments = volumes_v21.VolumeAttachmentController() - self.req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments/uuid', version='2.49') - - def test_tagged_volume_attach_invalid_tag_comma(self): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake', - 'tag': ','}} - self.assertRaises(exception.ValidationError, self.attachments.create, - self.req, FAKE_UUID, body=body) - - def test_tagged_volume_attach_invalid_tag_slash(self): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake', - 'tag': '/'}} - self.assertRaises(exception.ValidationError, self.attachments.create, - self.req, FAKE_UUID, body=body) - - def test_tagged_volume_attach_invalid_tag_too_long(self): - tag = ''.join(map(str, range(10, 41))) - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake', - 'tag': tag}} - self.assertRaises(exception.ValidationError, self.attachments.create, - self.req, FAKE_UUID, body=body) - - @mock.patch('nova.compute.api.API.attach_volume') - @mock.patch('nova.compute.api.API.get', fake_get_instance) - def test_tagged_volume_attach_valid_tag(self, _): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake', - 'tag': 'foo'}} - self.attachments.create(self.req, FAKE_UUID, body=body) - - -class VolumeAttachTestsV260(test.NoDBTestCase): - """Negative tests for attaching a multiattach volume with version 2.60.""" - - def setUp(self): - super(VolumeAttachTestsV260, self).setUp() - self.controller = volumes_v21.VolumeAttachmentController() - get_instance = mock.patch('nova.compute.api.API.get') - get_instance.side_effect = fake_get_instance - get_instance.start() - self.addCleanup(get_instance.stop) - - def _post_attach(self, version=None): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A}} - req = fakes.HTTPRequestV21.blank( - '/servers/%s/os-volume_attachments' % FAKE_UUID, - version=version or '2.60') - req.body = jsonutils.dump_as_bytes(body) - req.method = 'POST' - req.headers['content-type'] = 'application/json' - return self.controller.create(req, FAKE_UUID, body=body) - - def test_attach_with_multiattach_fails_old_microversion(self): - """Tests the case that the user tries to attach with a - multiattach volume but before using microversion 2.60. - """ - with mock.patch.object( - self.controller.compute_api, 'attach_volume', - side_effect= - exception.MultiattachNotSupportedOldMicroversion) as attach: - ex = self.assertRaises(webob.exc.HTTPBadRequest, - self._post_attach, '2.59') - create_kwargs = attach.call_args[1] - self.assertFalse(create_kwargs['supports_multiattach']) - self.assertIn('Multiattach volumes are only supported starting with ' - 'compute API version 2.60', str(ex)) - - def test_attach_with_multiattach_fails_not_supported_by_driver(self): - """Tests the case that the user tries to attach with a - multiattach volume but the compute hosting the instance does - not support multiattach volumes. This would come from - reserve_block_device_name via RPC call to the compute service. - """ - with mock.patch.object( - self.controller.compute_api, 'attach_volume', - side_effect= - exception.MultiattachNotSupportedByVirtDriver( - volume_id=FAKE_UUID_A)) as attach: - ex = self.assertRaises(webob.exc.HTTPBadRequest, self._post_attach) - create_kwargs = attach.call_args[1] - self.assertTrue(create_kwargs['supports_multiattach']) - self.assertIn("has 'multiattach' set, which is not supported for " - "this instance", str(ex)) - - def test_attach_with_multiattach_fails_for_shelved_offloaded_server(self): - """Tests the case that the user tries to attach with a - multiattach volume to a shelved offloaded server which is - not supported. - """ - with mock.patch.object( - self.controller.compute_api, 'attach_volume', - side_effect= - exception.MultiattachToShelvedNotSupported) as attach: - ex = self.assertRaises(webob.exc.HTTPBadRequest, self._post_attach) - create_kwargs = attach.call_args[1] - self.assertTrue(create_kwargs['supports_multiattach']) - self.assertIn('Attaching multiattach volumes is not supported for ' - 'shelved-offloaded instances.', str(ex)) - - -class VolumeAttachTestsV2_75(VolumeAttachTestsV21): - microversion = '2.75' - - def setUp(self): - super(VolumeAttachTestsV2_75, self).setUp() - self.expected_show = {'volumeAttachment': - {'device': '/dev/fake0', - 'serverId': FAKE_UUID, - 'id': FAKE_UUID_A, - 'volumeId': FAKE_UUID_A, - 'tag': None, - }} - - @mock.patch.object(objects.BlockDeviceMappingList, - 'get_by_instance_uuid') - def test_list_with_additional_filter_old_version(self, mock_get): - fake_bdms = objects.BlockDeviceMappingList() - mock_get.return_value = fake_bdms - req = fakes.HTTPRequest.blank( - '/os-volumes?limit=1&offset=1&additional=something', - version='2.74') - self.attachments.index(req, FAKE_UUID) - - def test_list_with_additional_filter(self): - req = self._build_request( - '?limit=1&additional=something') - self.assertRaises(self.validation_error, self.attachments.index, - req, FAKE_UUID) - - -class VolumeAttachTestsV279(VolumeAttachTestsV2_75): - microversion = '2.79' - - def setUp(self): - super(VolumeAttachTestsV279, self).setUp() - self.controller = volumes_v21.VolumeAttachmentController() - self.expected_show = {'volumeAttachment': - {'device': '/dev/fake0', - 'serverId': FAKE_UUID, - 'id': FAKE_UUID_A, - 'volumeId': FAKE_UUID_A, - 'tag': None, - 'delete_on_termination': False - }} - - def _get_req(self, body, microversion=None): - req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments/uuid', - version=microversion or self.microversion) - req.body = jsonutils.dump_as_bytes(body) - req.method = 'POST' - req.headers['content-type'] = 'application/json' - return req - - def test_create_volume_attach_pre_v279(self): - """Tests the case that the user tries to attach a volume with - delete_on_termination field, but before using microversion 2.79. - """ - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'delete_on_termination': False}} - req = self._get_req(body, microversion='2.78') - ex = self.assertRaises(exception.ValidationError, - self.controller.create, - req, FAKE_UUID, body=body) - self.assertIn("Additional properties are not allowed", str(ex)) - - @mock.patch('nova.compute.api.API.attach_volume', return_value=None) - def test_attach_volume_pre_v279(self, mock_attach_volume): - """Before microversion 2.79, attach a volume will not contain - 'delete_on_termination' field in the response. - """ - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A}} - req = self._get_req(body, microversion='2.78') - result = self.attachments.create(req, FAKE_UUID, body=body) - self.assertNotIn('delete_on_termination', result['volumeAttachment']) - mock_attach_volume.assert_called_once_with( - req.environ['nova.context'], test.MatchType(objects.Instance), - FAKE_UUID_A, None, tag=None, supports_multiattach=True, - delete_on_termination=False) - - @mock.patch('nova.compute.api.API.attach_volume', return_value=None) - def test_attach_volume_with_delete_on_termination_default_value( - self, mock_attach_volume): - """Test attach a volume doesn't specify 'delete_on_termination' in - the request, you will be get it's default value in the response. - The delete_on_termination's default value is 'False'. - """ - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A}} - req = self._get_req(body) - result = self.attachments.create(req, FAKE_UUID, body=body) - self.assertFalse(result['volumeAttachment']['delete_on_termination']) - mock_attach_volume.assert_called_once_with( - req.environ['nova.context'], test.MatchType(objects.Instance), - FAKE_UUID_A, None, tag=None, supports_multiattach=True, - delete_on_termination=False) - - def test_create_volume_attach_invalid_delete_on_termination_empty(self): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'delete_on_termination': None}} - req = self._get_req(body) - ex = self.assertRaises(exception.ValidationError, - self.controller.create, - req, FAKE_UUID, body=body) - self.assertIn("Invalid input for field/attribute " - "delete_on_termination.", str(ex)) - - def test_create_volume_attach_invalid_delete_on_termination_value(self): - """"Test the case that the user tries to set the delete_on_termination - value not in the boolean or string-boolean check, the valid boolean - value are: - - [True, 'True', 'TRUE', 'true', '1', 'ON', 'On', 'on', 'YES', 'Yes', - 'yes', False, 'False', 'FALSE', 'false', '0', 'OFF', 'Off', 'off', - 'NO', 'No', 'no'] - """ - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'delete_on_termination': 'foo'}} - req = self._get_req(body) - ex = self.assertRaises(exception.ValidationError, - self.controller.create, - req, FAKE_UUID, body=body) - self.assertIn("Invalid input for field/attribute " - "delete_on_termination.", str(ex)) - - @mock.patch('nova.compute.api.API.attach_volume', return_value=None) - def test_attach_volume_v279(self, mock_attach_volume): - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'delete_on_termination': True}} - req = self._get_req(body) - result = self.attachments.create(req, FAKE_UUID, body=body) - self.assertTrue(result['volumeAttachment']['delete_on_termination']) - mock_attach_volume.assert_called_once_with( - req.environ['nova.context'], test.MatchType(objects.Instance), - FAKE_UUID_A, None, tag=None, supports_multiattach=True, - delete_on_termination=True) - - def test_show_pre_v279(self): - """Before microversion 2.79, show a detail of a volume attachment - does not contain the 'delete_on_termination' field in the response - body. - """ - req = self._get_req(body={}, microversion='2.78') - req.method = 'GET' - result = self.attachments.show(req, FAKE_UUID, FAKE_UUID_A) - - self.assertNotIn('delete_on_termination', result['volumeAttachment']) - - @mock.patch('nova.objects.BlockDeviceMappingList.get_by_instance_uuid') - def test_list_pre_v279(self, mock_get_bdms): - """Before microversion 2.79, list of a volume attachment - does not contain the 'delete_on_termination' field in the response - body. - """ - req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments', - version="2.78") - req.body = jsonutils.dump_as_bytes({}) - req.method = 'GET' - req.headers['content-type'] = 'application/json' - - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=True, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - bdms = objects.BlockDeviceMappingList(objects=[vol_bdm]) - - mock_get_bdms.return_value = bdms - result = self.attachments.index(req, FAKE_UUID) - - self.assertNotIn('delete_on_termination', result['volumeAttachments']) - - -class UpdateVolumeAttachTests(VolumeAttachTestsV279): - microversion = '2.85' - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - def test_swap_volume(self, mock_save_bdm, mock_get_bdm): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=uuids.source_swap_vol, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_bdm.return_value = vol_bdm - # On the newer microversion, this test will try to look up the - # BDM to check for update of other fields. - super(UpdateVolumeAttachTests, self).test_swap_volume() - - def test_swap_volume_with_extra_arg(self): - # NOTE(danms): Override this from parent because now device - # is checked for unchanged-ness. - body = {'volumeAttachment': {'volumeId': FAKE_UUID_A, - 'device': '/dev/fake0', - 'notathing': 'foo'}} - - self.assertRaises(self.validation_error, - self._test_swap, - self.attachments, - body=body) - - @mock.patch.object(compute_api.API, 'swap_volume') - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - def test_update_volume(self, mock_bdm_save, - mock_get_vol_and_inst, mock_swap): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'tag': 'fake-tag', - 'delete_on_termination': True, - 'device': '/dev/fake0', - }} - self.attachments.update(self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - mock_swap.assert_not_called() - mock_bdm_save.assert_called_once() - self.assertTrue(vol_bdm['delete_on_termination']) - - @mock.patch.object(compute_api.API, 'swap_volume') - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - def test_update_volume_with_bool_from_string( - self, mock_bdm_save, mock_get_vol_and_inst, mock_swap): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=True, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'tag': 'fake-tag', - 'delete_on_termination': 'False', - 'device': '/dev/fake0', - }} - self.attachments.update(self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - mock_swap.assert_not_called() - mock_bdm_save.assert_called_once() - self.assertFalse(vol_bdm['delete_on_termination']) - - # Update delete_on_termination to False - body['volumeAttachment']['delete_on_termination'] = '0' - self.attachments.update(self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - mock_swap.assert_not_called() - mock_bdm_save.assert_called() - self.assertFalse(vol_bdm['delete_on_termination']) - - # Update delete_on_termination to True - body['volumeAttachment']['delete_on_termination'] = '1' - self.attachments.update(self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - mock_swap.assert_not_called() - mock_bdm_save.assert_called() - self.assertTrue(vol_bdm['delete_on_termination']) - - @mock.patch.object(compute_api.API, 'swap_volume') - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - def test_update_volume_swap(self, mock_bdm_save, - mock_get_vol_and_inst, mock_swap): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=uuids.source_swap_vol, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_B, - 'tag': 'fake-tag', - 'delete_on_termination': True, - }} - self.attachments.update(self.req, FAKE_UUID, - uuids.source_swap_vol, body=body) - mock_bdm_save.assert_called_once() - self.assertTrue(vol_bdm['delete_on_termination']) - # Swap volume is tested elsewhere, just make sure that we did - # attempt to call it in addition to updating the BDM - self.assertTrue(mock_swap.called) - - @mock.patch.object(compute_api.API, 'swap_volume') - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - def test_update_volume_swap_only_old_microversion( - self, mock_bdm_save, mock_get_vol_and_inst, mock_swap): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=uuids.source_swap_vol, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_B, - }} - req = self._get_req(body, microversion='2.84') - self.attachments.update(req, FAKE_UUID, - uuids.source_swap_vol, body=body) - mock_swap.assert_called_once() - mock_bdm_save.assert_not_called() - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance', - side_effect=exception.VolumeBDMNotFound( - volume_id=FAKE_UUID_A)) - def test_update_volume_with_invalid_volume_id(self, mock_mr): - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'delete_on_termination': True, - }} - self.assertRaises(exc.HTTPNotFound, - self.attachments.update, - self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - def test_update_volume_with_changed_attachment_id(self, - mock_get_vol_and_inst): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'id': uuids.attachment_id2, - }} - self.assertRaises(exc.HTTPBadRequest, - self.attachments.update, - self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - def test_update_volume_with_changed_attachment_id_old_microversion( - self, mock_get_vol_and_inst): - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'id': uuids.attachment_id, - }} - req = self._get_req(body, microversion='2.84') - ex = self.assertRaises(exception.ValidationError, - self.attachments.update, - req, FAKE_UUID, - FAKE_UUID_A, body=body) - self.assertIn('Additional properties are not allowed', str(ex)) - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - def test_update_volume_with_changed_serverId(self, - mock_get_vol_and_inst): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'serverId': uuids.server_id, - }} - self.assertRaises(exc.HTTPBadRequest, - self.attachments.update, - self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - def test_update_volume_with_changed_serverId_old_microversion( - self, mock_get_vol_and_inst): - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'serverId': uuids.server_id, - }} - req = self._get_req(body, microversion='2.84') - ex = self.assertRaises(exception.ValidationError, - self.attachments.update, - req, FAKE_UUID, - FAKE_UUID_A, body=body) - self.assertIn('Additional properties are not allowed', str(ex)) - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - def test_update_volume_with_changed_device(self, mock_get_vol_and_inst): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'device': '/dev/sdz', - }} - self.assertRaises(exc.HTTPBadRequest, - self.attachments.update, - self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - - def test_update_volume_with_device_name_old_microversion(self): - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'device': '/dev/fake0', - }} - req = self._get_req(body, microversion='2.84') - ex = self.assertRaises(exception.ValidationError, - self.attachments.update, - req, FAKE_UUID, - FAKE_UUID_A, body=body) - self.assertIn('Additional properties are not allowed', str(ex)) - - @mock.patch.object(objects.BlockDeviceMapping, - 'get_by_volume_and_instance') - def test_update_volume_with_changed_tag(self, mock_get_vol_and_inst): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=False, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - mock_get_vol_and_inst.return_value = vol_bdm - - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'tag': 'icanhaznewtag', - }} - self.assertRaises(exc.HTTPBadRequest, - self.attachments.update, - self.req, FAKE_UUID, - FAKE_UUID_A, body=body) - - def test_update_volume_with_tag_old_microversion(self): - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'tag': 'fake-tag', - }} - req = self._get_req(body, microversion='2.84') - ex = self.assertRaises(exception.ValidationError, - self.attachments.update, - req, FAKE_UUID, - FAKE_UUID_A, body=body) - self.assertIn('Additional properties are not allowed', str(ex)) - - def test_update_volume_with_delete_flag_old_microversion(self): - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'delete_on_termination': True, - }} - req = self._get_req(body, microversion='2.84') - ex = self.assertRaises(exception.ValidationError, - self.attachments.update, - req, FAKE_UUID, - FAKE_UUID_A, body=body) - self.assertIn('Additional properties are not allowed', str(ex)) - - -class VolumeAttachTestsV289(UpdateVolumeAttachTests): - microversion = '2.89' - - def setUp(self): - super().setUp() - self.controller = volumes_v21.VolumeAttachmentController() - self.expected_show = { - 'volumeAttachment': { - 'device': '/dev/fake0', - 'serverId': FAKE_UUID, - 'volumeId': FAKE_UUID_A, - 'tag': None, - 'delete_on_termination': False, - 'attachment_id': None, - 'bdm_uuid': uuids.bdm, - } - } - - def test_show_pre_v289(self): - req = self._get_req(body={}, microversion='2.88') - req.method = 'GET' - result = self.attachments.show(req, FAKE_UUID, FAKE_UUID_A) - self.assertIn('id', result['volumeAttachment']) - self.assertNotIn('bdm_uuid', result['volumeAttachment']) - self.assertNotIn('attachment_id', result['volumeAttachment']) - - @mock.patch('nova.objects.BlockDeviceMappingList.get_by_instance_uuid') - def test_list(self, mock_get_bdms): - vol_bdm = objects.BlockDeviceMapping( - self.context, - id=1, - uuid=uuids.bdm, - instance_uuid=FAKE_UUID, - volume_id=FAKE_UUID_A, - source_type='volume', - destination_type='volume', - delete_on_termination=True, - connection_info=None, - tag='fake-tag', - device_name='/dev/fake0', - attachment_id=uuids.attachment_id) - bdms = objects.BlockDeviceMappingList(objects=[vol_bdm]) - mock_get_bdms.return_value = bdms - - req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments', - version="2.88") - req.body = jsonutils.dump_as_bytes({}) - req.method = 'GET' - req.headers['content-type'] = 'application/json' - - result = self.attachments.index(req, FAKE_UUID) - self.assertIn('id', result['volumeAttachments'][0]) - self.assertNotIn('attachment_id', result['volumeAttachments'][0]) - self.assertNotIn('bdm_uuid', result['volumeAttachments'][0]) - - req = fakes.HTTPRequest.blank( - '/v2.1/servers/id/os-volume_attachments', - version="2.89") - req.body = jsonutils.dump_as_bytes({}) - req.method = 'GET' - req.headers['content-type'] = 'application/json' - - result = self.attachments.index(req, FAKE_UUID) - self.assertNotIn('id', result['volumeAttachments'][0]) - self.assertIn('attachment_id', result['volumeAttachments'][0]) - self.assertEqual( - uuids.attachment_id, - result['volumeAttachments'][0]['attachment_id'] - ) - self.assertIn('bdm_uuid', result['volumeAttachments'][0]) - self.assertEqual( - uuids.bdm, - result['volumeAttachments'][0]['bdm_uuid'] - ) - - -class SwapVolumeMultiattachTestCase(test.NoDBTestCase): - - @mock.patch('nova.api.openstack.common.get_instance') - @mock.patch('nova.volume.cinder.API.begin_detaching') - @mock.patch('nova.volume.cinder.API.roll_detaching') - def test_swap_multiattach_multiple_readonly_attachments_fails( - self, mock_roll_detaching, mock_begin_detaching, - mock_get_instance): - """Tests that trying to swap from a multiattach volume with - multiple read/write attachments will return an error. - """ - - def fake_volume_get(_context, volume_id): - if volume_id == uuids.old_vol_id: - return { - 'id': volume_id, - 'size': 1, - 'multiattach': True, - 'migration_status': 'migrating', - 'attachments': { - uuids.server1: { - 'attachment_id': uuids.attachment_id1, - 'mountpoint': '/dev/vdb' - }, - uuids.server2: { - 'attachment_id': uuids.attachment_id2, - 'mountpoint': '/dev/vdb' - } - } - } - if volume_id == uuids.new_vol_id: - return { - 'id': volume_id, - 'size': 1, - 'attach_status': 'detached' - } - raise exception.VolumeNotFound(volume_id=volume_id) - - def fake_attachment_get(_context, attachment_id): - return {'attach_mode': 'rw'} - - ctxt = context.get_admin_context() - instance = fake_instance.fake_instance_obj( - ctxt, uuid=uuids.server1, vm_state=vm_states.ACTIVE, - task_state=None, launched_at=datetime.datetime(2018, 6, 6)) - mock_get_instance.return_value = instance - controller = volumes_v21.VolumeAttachmentController() - with test.nested( - mock.patch.object(controller.volume_api, 'get', - side_effect=fake_volume_get), - mock.patch.object(controller.compute_api.volume_api, - 'attachment_get', - side_effect=fake_attachment_get)) as ( - mock_volume_get, mock_attachment_get - ): - req = fakes.HTTPRequest.blank( - '/servers/%s/os-volume_attachments/%s' % - (uuids.server1, uuids.old_vol_id)) - req.headers['content-type'] = 'application/json' - req.environ['nova.context'] = ctxt - body = { - 'volumeAttachment': { - 'volumeId': uuids.new_vol_id - } - } - ex = self.assertRaises( - webob.exc.HTTPBadRequest, controller.update, req, - uuids.server1, uuids.old_vol_id, body=body) - self.assertIn( - 'Swapping multi-attach volumes with more than one ', str(ex)) - mock_attachment_get.assert_has_calls([ - mock.call(ctxt, uuids.attachment_id1), - mock.call(ctxt, uuids.attachment_id2)], any_order=True) - mock_roll_detaching.assert_called_once_with(ctxt, uuids.old_vol_id) - - -class CommonBadRequestTestCase(object): - - resource = None - entity_name = None - controller_cls = None - kwargs = {} - bad_request = exc.HTTPBadRequest - - """ - Tests of places we throw 400 Bad Request from - """ - - def setUp(self): - super(CommonBadRequestTestCase, self).setUp() - self.controller = self.controller_cls() - - def _bad_request_create(self, body): - req = fakes.HTTPRequest.blank('/v2.1/%s' % self.resource) - req.method = 'POST' - - kwargs = self.kwargs.copy() - kwargs['body'] = body - self.assertRaises(self.bad_request, - self.controller.create, req, **kwargs) - - def test_create_no_body(self): - self._bad_request_create(body=None) - - def test_create_missing_volume(self): - body = {'foo': {'a': 'b'}} - self._bad_request_create(body=body) - - def test_create_malformed_entity(self): - body = {self.entity_name: 'string'} - self._bad_request_create(body=body) - - -class BadRequestVolumeTestCaseV21(CommonBadRequestTestCase, - test.NoDBTestCase): - - resource = 'os-volumes' - entity_name = 'volume' - controller_cls = volumes_v21.VolumeController - bad_request = exception.ValidationError - - @mock.patch.object(cinder.API, 'delete', - side_effect=exception.InvalidInput(reason='vol attach')) - def test_delete_invalid_status_volume(self, mock_delete): - req = fakes.HTTPRequest.blank('/v2.1/os-volumes') - req.method = 'DELETE' - self.assertRaises(webob.exc.HTTPBadRequest, - self.controller.delete, req, FAKE_UUID) - - -class BadRequestSnapshotTestCaseV21(CommonBadRequestTestCase, - test.NoDBTestCase): - - resource = 'os-snapshots' - entity_name = 'snapshot' - controller_cls = volumes_v21.SnapshotController - bad_request = exception.ValidationError - - -class AssistedSnapshotCreateTestCaseV21(test.NoDBTestCase): - bad_request = exception.ValidationError - - def setUp(self): - super(AssistedSnapshotCreateTestCaseV21, self).setUp() - - self.controller = assisted_snaps.AssistedVolumeSnapshotsController() - - @mock.patch.object(compute_api.API, 'volume_snapshot_create') - def test_assisted_create(self, mock_volume_snapshot_create): - mock_volume_snapshot_create.return_value = { - 'snapshot': { - 'id': uuids.snapshot_id, - 'volumeId': uuids.volume_id, - }, - } - req = fakes.HTTPRequest.blank('/v2.1/os-assisted-volume-snapshots') - expected_create_info = {'type': 'qcow2', - 'new_file': 'new_file', - 'snapshot_id': 'snapshot_id'} - body = {'snapshot': {'volume_id': uuids.volume_to_snapshot, - 'create_info': expected_create_info}} - req.method = 'POST' - self.controller.create(req, body=body) - - mock_volume_snapshot_create.assert_called_once_with( - req.environ['nova.context'], uuids.volume_to_snapshot, - expected_create_info) - - def test_assisted_create_missing_create_info(self): - req = fakes.HTTPRequest.blank('/v2.1/os-assisted-volume-snapshots') - body = {'snapshot': {'volume_id': '1'}} - req.method = 'POST' - self.assertRaises(self.bad_request, self.controller.create, - req, body=body) - - def test_assisted_create_with_unexpected_attr(self): - req = fakes.HTTPRequest.blank('/v2.1/os-assisted-volume-snapshots') - body = { - 'snapshot': { - 'volume_id': '1', - 'create_info': { - 'type': 'qcow2', - 'new_file': 'new_file', - 'snapshot_id': 'snapshot_id' - } - }, - 'unexpected': 0, - } - req.method = 'POST' - self.assertRaises(self.bad_request, self.controller.create, - req, body=body) - - @mock.patch('nova.objects.BlockDeviceMapping.get_by_volume', - side_effect=exception.VolumeBDMIsMultiAttach(volume_id='1')) - def test_assisted_create_multiattach_fails(self, bdm_get_by_volume): - req = fakes.HTTPRequest.blank('/v2.1/os-assisted-volume-snapshots') - body = {'snapshot': - {'volume_id': '1', - 'create_info': {'type': 'qcow2', - 'new_file': 'new_file', - 'snapshot_id': 'snapshot_id'}}} - req.method = 'POST' - self.assertRaises( - webob.exc.HTTPBadRequest, self.controller.create, req, body=body) - - def _test_assisted_create_instance_conflict(self, api_error): - req = fakes.HTTPRequest.blank('/v2.1/os-assisted-volume-snapshots') - body = {'snapshot': - {'volume_id': '1', - 'create_info': {'type': 'qcow2', - 'new_file': 'new_file', - 'snapshot_id': 'snapshot_id'}}} - req.method = 'POST' - with mock.patch.object(compute_api.API, 'volume_snapshot_create', - side_effect=api_error): - self.assertRaises( - webob.exc.HTTPBadRequest, self.controller.create, - req, body=body) - - def test_assisted_create_instance_invalid_state(self): - api_error = exception.InstanceInvalidState( - instance_uuid=FAKE_UUID, attr='task_state', - state=task_states.SHELVING_OFFLOADING, - method='volume_snapshot_create') - self._test_assisted_create_instance_conflict(api_error) - - def test_assisted_create_instance_not_ready(self): - api_error = exception.InstanceNotReady(instance_id=FAKE_UUID) - self._test_assisted_create_instance_conflict(api_error) - - -class AssistedSnapshotDeleteTestCaseV21(test.NoDBTestCase): - microversion = '2.1' - - def _check_status(self, expected_status, req, res, controller_method): - self.assertEqual(expected_status, controller_method.wsgi_codes(req)) - - def setUp(self): - super(AssistedSnapshotDeleteTestCaseV21, self).setUp() - - self.controller = assisted_snaps.AssistedVolumeSnapshotsController() - self.mock_volume_snapshot_delete = self.useFixture( - fixtures.MockPatchObject(compute_api.API, - 'volume_snapshot_delete')).mock - - def test_assisted_delete(self): - params = { - 'delete_info': jsonutils.dumps({'volume_id': '1'}), - } - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots?%s' % - urllib.parse.urlencode(params), - version=self.microversion) - req.method = 'DELETE' - result = self.controller.delete(req, '5') - self._check_status(204, req, result, self.controller.delete) - - def test_assisted_delete_missing_delete_info(self): - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots', - version=self.microversion) - req.method = 'DELETE' - self.assertRaises(webob.exc.HTTPBadRequest, self.controller.delete, - req, '5') - - def _test_assisted_delete_instance_conflict(self, api_error): - self.mock_volume_snapshot_delete.side_effect = api_error - params = { - 'delete_info': jsonutils.dumps({'volume_id': '1'}), - } - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots?%s' % - urllib.parse.urlencode(params), - version=self.microversion) - req.method = 'DELETE' - - self.assertRaises( - webob.exc.HTTPBadRequest, self.controller.delete, req, '5') - - def test_assisted_delete_instance_invalid_state(self): - api_error = exception.InstanceInvalidState( - instance_uuid=FAKE_UUID, attr='task_state', - state=task_states.UNSHELVING, - method='volume_snapshot_delete') - self._test_assisted_delete_instance_conflict(api_error) - - def test_assisted_delete_instance_not_ready(self): - api_error = exception.InstanceNotReady(instance_id=FAKE_UUID) - self._test_assisted_delete_instance_conflict(api_error) - - def test_delete_additional_query_parameters(self): - params = { - 'delete_info': jsonutils.dumps({'volume_id': '1'}), - 'additional': 123 - } - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots?%s' % - urllib.parse.urlencode(params), - version=self.microversion) - req.method = 'DELETE' - self.controller.delete(req, '5') - - def test_delete_duplicate_query_parameters_validation(self): - params = [ - ('delete_info', jsonutils.dumps({'volume_id': '1'})), - ('delete_info', jsonutils.dumps({'volume_id': '2'})) - ] - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots?%s' % - urllib.parse.urlencode(params), - version=self.microversion) - req.method = 'DELETE' - self.controller.delete(req, '5') - - def test_assisted_delete_missing_volume_id(self): - params = { - 'delete_info': jsonutils.dumps({'something_else': '1'}), - } - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots?%s' % - urllib.parse.urlencode(params), - version=self.microversion) - - req.method = 'DELETE' - ex = self.assertRaises(webob.exc.HTTPBadRequest, - self.controller.delete, req, '5') - # This is the result of a KeyError but the only thing in the message - # is the missing key. - self.assertIn('volume_id', str(ex)) - - -class AssistedSnapshotDeleteTestCaseV275(AssistedSnapshotDeleteTestCaseV21): - microversion = '2.75' - - def test_delete_additional_query_parameters_old_version(self): - params = { - 'delete_info': jsonutils.dumps({'volume_id': '1'}), - 'additional': 123 - } - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots?%s' % - urllib.parse.urlencode(params), - version='2.74') - self.controller.delete(req, 1) - - def test_delete_additional_query_parameters(self): - req = fakes.HTTPRequest.blank( - '/v2.1/os-assisted-volume-snapshots?unknown=1', - version=self.microversion) - self.assertRaises(exception.ValidationError, - self.controller.delete, req, 1) + '/os-volumes/detail?limit=1&offset=1&additional=something') + self.controller.index(req) class TestVolumesAPIDeprecation(test.NoDBTestCase): def setUp(self): - super(TestVolumesAPIDeprecation, self).setUp() + super().setUp() self.controller = volumes_v21.VolumeController() self.req = fakes.HTTPRequest.blank('', version='2.36') diff --git a/nova/tests/unit/policies/test_snapshots.py b/nova/tests/unit/policies/test_snapshots.py new file mode 100644 index 000000000000..901e97fbe6c9 --- /dev/null +++ b/nova/tests/unit/policies/test_snapshots.py @@ -0,0 +1,242 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from oslo_utils.fixture import uuidsentinel as uuids + +from nova.api.openstack.compute import snapshots +from nova.policies import base as base_policy +from nova.policies import volumes as v_policies +from nova.tests.unit.api.openstack import fakes +from nova.tests.unit.policies import base + + +class SnapshotsPolicyTest(base.BasePolicyTest): + """Test Snapshots APIs policies with all possible context. + + This class defines the set of context with different roles + which are allowed and not allowed to pass the policy checks. + With those set of context, it will call the API operation and + verify the expected behaviour. + """ + + def setUp(self): + super().setUp() + self.snapshot_ctlr = snapshots.SnapshotController() + self.req = fakes.HTTPRequest.blank('') + # Everyone will be able to perform crud operations + # on volume and volume snapshots. + # NOTE: Nova cannot verify the volume/snapshot owner during nova policy + # enforcement so will be passing context's project_id as target to + # policy and always pass. If requester is not admin or owner + # of volume/snapshot then cinder will be returning the appropriate + # error. + self.project_member_authorized_contexts = [ + self.legacy_admin_context, self.system_admin_context, + self.project_admin_context, self.project_manager_context, + self.project_member_context, self.project_reader_context, + self.project_foo_context, + self.other_project_reader_context, + self.system_member_context, self.system_reader_context, + self.system_foo_context, + self.other_project_manager_context, + self.other_project_member_context + ] + self.project_reader_authorized_contexts = [ + self.legacy_admin_context, self.system_admin_context, + self.project_admin_context, self.project_manager_context, + self.project_member_context, self.project_reader_context, + self.project_foo_context, + self.other_project_reader_context, + self.system_member_context, self.system_reader_context, + self.system_foo_context, + self.other_project_manager_context, + self.other_project_member_context + ] + + @mock.patch('nova.volume.cinder.API.get_all_snapshots') + def test_list_snapshots_policy(self, mock_get): + rule_name = "os_compute_api:os-volumes:snapshots:list" + self.common_policy_auth(self.project_reader_authorized_contexts, + rule_name, self.snapshot_ctlr.index, + self.req) + + @mock.patch('nova.volume.cinder.API.get_all_snapshots') + def test_list_detail_snapshots_policy(self, mock_get): + rule_name = "os_compute_api:os-volumes:snapshots:detail" + self.common_policy_auth(self.project_reader_authorized_contexts, + rule_name, self.snapshot_ctlr.detail, + self.req) + + @mock.patch('nova.volume.cinder.API.get_snapshot') + def test_show_snapshot_policy(self, mock_get): + rule_name = "os_compute_api:os-volumes:snapshots:show" + self.common_policy_auth(self.project_reader_authorized_contexts, + rule_name, self.snapshot_ctlr.show, + self.req, uuids.fake_id) + + @mock.patch('nova.volume.cinder.API.create_snapshot') + def test_create_snapshot_policy(self, mock_create): + rule_name = "os_compute_api:os-volumes:snapshots:create" + body = {"snapshot": {"volume_id": uuids.fake_id}} + self.common_policy_auth(self.project_member_authorized_contexts, + rule_name, self.snapshot_ctlr.create, + self.req, body=body) + + @mock.patch('nova.volume.cinder.API.delete_snapshot') + def test_delete_snapshot_policy(self, mock_delete): + rule_name = "os_compute_api:os-volumes:snapshots:delete" + self.common_policy_auth(self.project_member_authorized_contexts, + rule_name, self.snapshot_ctlr.delete, + self.req, uuids.fake_id) + + +class SnapshotsNoLegacyNoScopePolicyTest(SnapshotsPolicyTest): + """Test Snapshot APIs policies with no legacy deprecated rules + and no scope checks which means new defaults only. + + """ + + without_deprecated_rules = True + rules_without_deprecation = { + v_policies.POLICY_NAME % 'list': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'detail': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'show': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'create': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'delete': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:list': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:detail': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:delete': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:create': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:show': + base_policy.PROJECT_READER_OR_ADMIN, + } + + def setUp(self): + super().setUp() + # With no legacy, project other roles like foo will not be able + # to operate on volume and snapshot. + self.project_member_authorized_contexts = [ + self.legacy_admin_context, self.system_admin_context, + self.project_admin_context, self.project_manager_context, + self.project_member_context, self.system_member_context, + self.other_project_manager_context, + self.other_project_member_context + ] + self.project_reader_authorized_contexts = [ + self.legacy_admin_context, self.system_admin_context, + self.project_admin_context, self.project_manager_context, + self.project_member_context, self.project_reader_context, + self.other_project_reader_context, + self.system_member_context, self.system_reader_context, + self.other_project_manager_context, + self.other_project_member_context + ] + + +class SnapshotsScopeTypePolicyTest(SnapshotsPolicyTest): + """Test Snapshots APIs policies with system scope enabled. + + This class set the nova.conf [oslo_policy] enforce_scope to True + so that we can switch on the scope checking on oslo policy side. + It defines the set of context with scoped token + which are allowed and not allowed to pass the policy checks. + With those set of context, it will run the API operation and + verify the expected behaviour. + """ + + def setUp(self): + super().setUp() + self.flags(enforce_scope=True, group="oslo_policy") + # With scope enabled, system users will not be able to + # operate on volume and snapshot. + self.project_member_authorized_contexts = [ + self.legacy_admin_context, self.project_admin_context, + self.project_manager_context, + self.project_member_context, + self.project_reader_context, self.project_foo_context, + self.other_project_reader_context, + self.other_project_manager_context, + self.other_project_member_context + ] + self.project_reader_authorized_contexts = [ + self.legacy_admin_context, self.project_admin_context, + self.project_manager_context, + self.project_member_context, + self.project_reader_context, self.project_foo_context, + self.other_project_reader_context, + self.other_project_manager_context, + self.other_project_member_context + ] + + +class SnapshotsScopeTypeNoLegacyPolicyTest(SnapshotsScopeTypePolicyTest): + """Test Snapshot APIs policies with system scope enabled, + and no legacy deprecated rules. + """ + without_deprecated_rules = True + + rules_without_deprecation = { + v_policies.POLICY_NAME % 'list': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'detail': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'show': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'create': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'delete': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:list': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:detail': + base_policy.PROJECT_READER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:delete': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:create': + base_policy.PROJECT_MEMBER_OR_ADMIN, + v_policies.POLICY_NAME % 'snapshots:show': + base_policy.PROJECT_READER_OR_ADMIN, + } + + def setUp(self): + super().setUp() + self.flags(enforce_scope=True, group="oslo_policy") + # With no legacy and scope enabled, system users and project + # other roles like foo will not be able to operate on volume + # and snapshot. + self.project_member_authorized_contexts = [ + self.legacy_admin_context, self.project_admin_context, + self.project_manager_context, + self.project_member_context, + self.other_project_manager_context, + self.other_project_member_context + ] + self.project_reader_authorized_contexts = [ + self.legacy_admin_context, self.project_admin_context, + self.project_manager_context, + self.project_member_context, + self.project_reader_context, + self.other_project_manager_context, + self.other_project_reader_context, + self.other_project_member_context + ] diff --git a/nova/tests/unit/policies/test_volume_attachments.py b/nova/tests/unit/policies/test_volume_attachments.py new file mode 100644 index 000000000000..8696f7b5084f --- /dev/null +++ b/nova/tests/unit/policies/test_volume_attachments.py @@ -0,0 +1,276 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +import fixtures +from oslo_utils.fixture import uuidsentinel as uuids +from oslo_utils import timeutils + +from nova.api.openstack.compute import volume_attachments +from nova.compute import vm_states +from nova import exception +from nova import objects +from nova.objects import block_device as block_device_obj +from nova.policies import volumes_attachments as va_policies +from nova.tests.unit.api.openstack import fakes +from nova.tests.unit import fake_block_device +from nova.tests.unit import fake_instance +from nova.tests.unit.policies import base + +# This is the server ID. +FAKE_UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' +# This is the old volume ID (to swap from). +FAKE_UUID_A = '00000000-aaaa-aaaa-aaaa-000000000000' +# This is the new volume ID (to swap to). +FAKE_UUID_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + + +def fake_bdm_get_by_volume_and_instance(cls, ctxt, volume_id, instance_uuid): + if volume_id not in (FAKE_UUID_A, uuids.source_swap_vol): + raise exception.VolumeBDMNotFound(volume_id=volume_id) + db_bdm = fake_block_device.FakeDbBlockDeviceDict( + {'id': 1, + 'instance_uuid': instance_uuid, + 'device_name': '/dev/fake0', + 'delete_on_termination': 'False', + 'source_type': 'volume', + 'destination_type': 'volume', + 'snapshot_id': None, + 'volume_id': volume_id, + 'volume_size': 1}) + return objects.BlockDeviceMapping._from_db_object( + ctxt, objects.BlockDeviceMapping(), db_bdm) + + +def fake_get_volume(self, context, id): + migration_status = None + if id == FAKE_UUID_A: + status = 'in-use' + attach_status = 'attached' + elif id == FAKE_UUID_B: + status = 'available' + attach_status = 'detached' + elif id == uuids.source_swap_vol: + status = 'in-use' + attach_status = 'attached' + migration_status = 'migrating' + else: + raise exception.VolumeNotFound(volume_id=id) + return { + 'id': id, 'status': status, 'attach_status': attach_status, + 'migration_status': migration_status + } + + +class VolumeAttachPolicyTest(base.BasePolicyTest): + """Test os-volumes-attachments APIs policies with all possible context. + + This class defines the set of context with different roles + which are allowed and not allowed to pass the policy checks. + With those set of context, it will call the API operation and + verify the expected behaviour. + """ + + def setUp(self): + super().setUp() + self.controller = volume_attachments.VolumeAttachmentController() + self.req = fakes.HTTPRequest.blank('') + self.policy_root = va_policies.POLICY_ROOT + self.stub_out('nova.objects.BlockDeviceMapping' + '.get_by_volume_and_instance', + fake_bdm_get_by_volume_and_instance) + self.stub_out('nova.volume.cinder.API.get', fake_get_volume) + + self.mock_get = self.useFixture( + fixtures.MockPatch('nova.api.openstack.common.get_instance')).mock + uuid = uuids.fake_id + self.instance = fake_instance.fake_instance_obj( + self.project_member_context, + id=1, uuid=uuid, project_id=self.project_id, + vm_state=vm_states.ACTIVE, + task_state=None, launched_at=timeutils.utcnow()) + self.mock_get.return_value = self.instance + + # With legacy rule and no scope checks, all admin, project members + # project reader or other project role(because legacy rule allow + # resource owner- having same project id and no role check) is + # able create/delete/update the volume attachment. + self.project_member_authorized_contexts = [ + self.legacy_admin_context, self.system_admin_context, + self.project_admin_context, self.project_manager_context, + self.project_member_context, self.project_reader_context, + self.project_foo_context] + + # With legacy rule and no scope checks, all admin, project members + # project reader or other project role(because legacy rule allow + # resource owner- having same project id and no role check) is + # able get the volume attachment. + self.project_reader_authorized_contexts = ( + self.project_member_authorized_contexts) + + # By default, legacy rule are enable and scope check is disabled. + # system admin, legacy admin, and project admin is able to update + # volume attachment with a different volumeId. + self.project_admin_authorized_contexts = [ + self.legacy_admin_context, self.system_admin_context, + self.project_admin_context] + + @mock.patch.object(objects.BlockDeviceMappingList, 'get_by_instance_uuid') + def test_index_volume_attach_policy(self, mock_get_instance): + rule_name = self.policy_root % "index" + self.common_policy_auth(self.project_reader_authorized_contexts, + rule_name, self.controller.index, + self.req, FAKE_UUID) + + def test_show_volume_attach_policy(self): + rule_name = self.policy_root % "show" + self.common_policy_auth(self.project_reader_authorized_contexts, + rule_name, self.controller.show, + self.req, FAKE_UUID, FAKE_UUID_A) + + @mock.patch('nova.compute.api.API.attach_volume') + def test_create_volume_attach_policy(self, mock_attach_volume): + rule_name = self.policy_root % "create" + body = {'volumeAttachment': {'volumeId': FAKE_UUID_B, + 'device': '/dev/fake'}} + self.common_policy_auth(self.project_member_authorized_contexts, + rule_name, self.controller.create, + self.req, FAKE_UUID, body=body) + + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + def test_update_volume_attach_policy(self, mock_bdm_save): + rule_name = self.policy_root % "update" + req = fakes.HTTPRequest.blank('', version='2.85') + body = {'volumeAttachment': { + 'volumeId': FAKE_UUID_A, + 'delete_on_termination': True}} + self.common_policy_auth(self.project_member_authorized_contexts, + rule_name, self.controller.update, + req, FAKE_UUID, + FAKE_UUID_A, body=body) + + @mock.patch('nova.compute.api.API.detach_volume') + def test_delete_volume_attach_policy(self, mock_detach_volume): + rule_name = self.policy_root % "delete" + self.common_policy_auth(self.project_member_authorized_contexts, + rule_name, self.controller.delete, + self.req, FAKE_UUID, FAKE_UUID_A) + + @mock.patch('nova.compute.api.API.swap_volume') + def test_swap_volume_attach_policy(self, mock_swap_volume): + rule_name = self.policy_root % "swap" + body = {'volumeAttachment': {'volumeId': FAKE_UUID_B}} + self.common_policy_auth( + self.project_admin_authorized_contexts, + rule_name, self.controller.update, + self.req, FAKE_UUID, uuids.source_swap_vol, body=body) + + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + @mock.patch('nova.compute.api.API.swap_volume') + def test_swap_volume_attach_policy_failed(self, + mock_swap_volume, + mock_bdm_save): + """Policy check fails for swap + update due to swap policy failure. + """ + rule_name = self.policy_root % "swap" + req = fakes.HTTPRequest.blank('', version='2.85') + req.environ['nova.context'].user_id = 'other-user' + self.policy.set_rules({rule_name: "user_id:%(user_id)s"}) + body = {'volumeAttachment': {'volumeId': FAKE_UUID_B, + 'delete_on_termination': True}} + exc = self.assertRaises( + exception.PolicyNotAuthorized, self.controller.update, + req, FAKE_UUID, FAKE_UUID_A, body=body) + self.assertEqual( + "Policy doesn't allow %s to be performed." % rule_name, + exc.format_message()) + mock_swap_volume.assert_not_called() + mock_bdm_save.assert_not_called() + + @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') + @mock.patch('nova.compute.api.API.swap_volume') + def test_pass_swap_and_update_volume_attach_policy(self, + mock_swap_volume, + mock_bdm_save): + rule_name = self.policy_root % "swap" + req = fakes.HTTPRequest.blank('', version='2.85') + body = {'volumeAttachment': {'volumeId': FAKE_UUID_B, + 'delete_on_termination': True}} + self.common_policy_auth( + self.project_admin_authorized_contexts, + rule_name, self.controller.update, + req, FAKE_UUID, uuids.source_swap_vol, body=body) + mock_swap_volume.assert_called() + mock_bdm_save.assert_called() + + +class VolumeAttachNoLegacyNoScopePolicyTest(VolumeAttachPolicyTest): + """Test volume attachment APIs policies with no legacy deprecated rules + and no scope checks which means new defaults only. + + """ + + without_deprecated_rules = True + + def setUp(self): + super().setUp() + # With no legacy rule, only admin, member, or reader will be + # able to perform volume attachment operation on its own project. + self.project_member_authorized_contexts = ( + self.project_member_or_admin_with_no_scope_no_legacy) + self.project_reader_authorized_contexts = ( + self.project_reader_or_admin_with_no_scope_no_legacy) + + +class VolumeAttachScopeTypePolicyTest(VolumeAttachPolicyTest): + """Test os-volume-attachments APIs policies with system scope enabled. + + This class set the nova.conf [oslo_policy] enforce_scope to True + so that we can switch on the scope checking on oslo policy side. + It defines the set of context with scoped token + which are allowed and not allowed to pass the policy checks. + With those set of context, it will run the API operation and + verify the expected behaviour. + """ + + def setUp(self): + super().setUp() + self.flags(enforce_scope=True, group="oslo_policy") + + # Scope enable will not allow system admin to perform the + # volume attachments. + self.project_member_authorized_contexts = ( + self.project_m_r_or_admin_with_scope_and_legacy) + self.project_reader_authorized_contexts = ( + self.project_m_r_or_admin_with_scope_and_legacy) + + self.project_admin_authorized_contexts = [ + self.legacy_admin_context, self.project_admin_context] + + +class VolumeAttachScopeTypeNoLegacyPolicyTest(VolumeAttachScopeTypePolicyTest): + """Test os-volume-attachments APIs policies with system scope enabled, + and no legacy deprecated rules. + """ + without_deprecated_rules = True + + def setUp(self): + super().setUp() + self.flags(enforce_scope=True, group="oslo_policy") + # With scope enable and no legacy rule, it will not allow + # system users and project admin/member/reader will be able to + # perform volume attachment operation on its own project. + self.project_member_authorized_contexts = ( + self.project_member_or_admin_with_scope_no_legacy) + self.project_reader_authorized_contexts = ( + self.project_reader_or_admin_with_scope_no_legacy) diff --git a/nova/tests/unit/policies/test_volumes.py b/nova/tests/unit/policies/test_volumes.py index 2c1219123006..8459322f73c3 100644 --- a/nova/tests/unit/policies/test_volumes.py +++ b/nova/tests/unit/policies/test_volumes.py @@ -12,271 +12,14 @@ from unittest import mock -import fixtures from oslo_utils.fixture import uuidsentinel as uuids -from oslo_utils import timeutils -from nova.api.openstack.compute import volumes as volumes_v21 -from nova.compute import vm_states -from nova import exception -from nova import objects -from nova.objects import block_device as block_device_obj +from nova.api.openstack.compute import volumes from nova.policies import base as base_policy from nova.policies import volumes as v_policies -from nova.policies import volumes_attachments as va_policies from nova.tests.unit.api.openstack import fakes -from nova.tests.unit import fake_block_device -from nova.tests.unit import fake_instance from nova.tests.unit.policies import base -# This is the server ID. -FAKE_UUID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -# This is the old volume ID (to swap from). -FAKE_UUID_A = '00000000-aaaa-aaaa-aaaa-000000000000' -# This is the new volume ID (to swap to). -FAKE_UUID_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' - - -def fake_bdm_get_by_volume_and_instance(cls, ctxt, volume_id, instance_uuid): - if volume_id not in (FAKE_UUID_A, uuids.source_swap_vol): - raise exception.VolumeBDMNotFound(volume_id=volume_id) - db_bdm = fake_block_device.FakeDbBlockDeviceDict( - {'id': 1, - 'instance_uuid': instance_uuid, - 'device_name': '/dev/fake0', - 'delete_on_termination': 'False', - 'source_type': 'volume', - 'destination_type': 'volume', - 'snapshot_id': None, - 'volume_id': volume_id, - 'volume_size': 1}) - return objects.BlockDeviceMapping._from_db_object( - ctxt, objects.BlockDeviceMapping(), db_bdm) - - -def fake_get_volume(self, context, id): - migration_status = None - if id == FAKE_UUID_A: - status = 'in-use' - attach_status = 'attached' - elif id == FAKE_UUID_B: - status = 'available' - attach_status = 'detached' - elif id == uuids.source_swap_vol: - status = 'in-use' - attach_status = 'attached' - migration_status = 'migrating' - else: - raise exception.VolumeNotFound(volume_id=id) - return { - 'id': id, 'status': status, 'attach_status': attach_status, - 'migration_status': migration_status - } - - -class VolumeAttachPolicyTest(base.BasePolicyTest): - """Test os-volumes-attachments APIs policies with all possible context. - - This class defines the set of context with different roles - which are allowed and not allowed to pass the policy checks. - With those set of context, it will call the API operation and - verify the expected behaviour. - """ - - def setUp(self): - super(VolumeAttachPolicyTest, self).setUp() - self.controller = volumes_v21.VolumeAttachmentController() - self.req = fakes.HTTPRequest.blank('') - self.policy_root = va_policies.POLICY_ROOT - self.stub_out('nova.objects.BlockDeviceMapping' - '.get_by_volume_and_instance', - fake_bdm_get_by_volume_and_instance) - self.stub_out('nova.volume.cinder.API.get', fake_get_volume) - - self.mock_get = self.useFixture( - fixtures.MockPatch('nova.api.openstack.common.get_instance')).mock - uuid = uuids.fake_id - self.instance = fake_instance.fake_instance_obj( - self.project_member_context, - id=1, uuid=uuid, project_id=self.project_id, - vm_state=vm_states.ACTIVE, - task_state=None, launched_at=timeutils.utcnow()) - self.mock_get.return_value = self.instance - - # With legacy rule and no scope checks, all admin, project members - # project reader or other project role(because legacy rule allow - # resource owner- having same project id and no role check) is - # able create/delete/update the volume attachment. - self.project_member_authorized_contexts = [ - self.legacy_admin_context, self.system_admin_context, - self.project_admin_context, self.project_manager_context, - self.project_member_context, self.project_reader_context, - self.project_foo_context] - - # With legacy rule and no scope checks, all admin, project members - # project reader or other project role(because legacy rule allow - # resource owner- having same project id and no role check) is - # able get the volume attachment. - self.project_reader_authorized_contexts = ( - self.project_member_authorized_contexts) - - # By default, legacy rule are enable and scope check is disabled. - # system admin, legacy admin, and project admin is able to update - # volume attachment with a different volumeId. - self.project_admin_authorized_contexts = [ - self.legacy_admin_context, self.system_admin_context, - self.project_admin_context] - - @mock.patch.object(objects.BlockDeviceMappingList, 'get_by_instance_uuid') - def test_index_volume_attach_policy(self, mock_get_instance): - rule_name = self.policy_root % "index" - self.common_policy_auth(self.project_reader_authorized_contexts, - rule_name, self.controller.index, - self.req, FAKE_UUID) - - def test_show_volume_attach_policy(self): - rule_name = self.policy_root % "show" - self.common_policy_auth(self.project_reader_authorized_contexts, - rule_name, self.controller.show, - self.req, FAKE_UUID, FAKE_UUID_A) - - @mock.patch('nova.compute.api.API.attach_volume') - def test_create_volume_attach_policy(self, mock_attach_volume): - rule_name = self.policy_root % "create" - body = {'volumeAttachment': {'volumeId': FAKE_UUID_B, - 'device': '/dev/fake'}} - self.common_policy_auth(self.project_member_authorized_contexts, - rule_name, self.controller.create, - self.req, FAKE_UUID, body=body) - - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - def test_update_volume_attach_policy(self, mock_bdm_save): - rule_name = self.policy_root % "update" - req = fakes.HTTPRequest.blank('', version='2.85') - body = {'volumeAttachment': { - 'volumeId': FAKE_UUID_A, - 'delete_on_termination': True}} - self.common_policy_auth(self.project_member_authorized_contexts, - rule_name, self.controller.update, - req, FAKE_UUID, - FAKE_UUID_A, body=body) - - @mock.patch('nova.compute.api.API.detach_volume') - def test_delete_volume_attach_policy(self, mock_detach_volume): - rule_name = self.policy_root % "delete" - self.common_policy_auth(self.project_member_authorized_contexts, - rule_name, self.controller.delete, - self.req, FAKE_UUID, FAKE_UUID_A) - - @mock.patch('nova.compute.api.API.swap_volume') - def test_swap_volume_attach_policy(self, mock_swap_volume): - rule_name = self.policy_root % "swap" - body = {'volumeAttachment': {'volumeId': FAKE_UUID_B}} - self.common_policy_auth( - self.project_admin_authorized_contexts, - rule_name, self.controller.update, - self.req, FAKE_UUID, uuids.source_swap_vol, body=body) - - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - @mock.patch('nova.compute.api.API.swap_volume') - def test_swap_volume_attach_policy_failed(self, - mock_swap_volume, - mock_bdm_save): - """Policy check fails for swap + update due to swap policy failure. - """ - rule_name = self.policy_root % "swap" - req = fakes.HTTPRequest.blank('', version='2.85') - req.environ['nova.context'].user_id = 'other-user' - self.policy.set_rules({rule_name: "user_id:%(user_id)s"}) - body = {'volumeAttachment': {'volumeId': FAKE_UUID_B, - 'delete_on_termination': True}} - exc = self.assertRaises( - exception.PolicyNotAuthorized, self.controller.update, - req, FAKE_UUID, FAKE_UUID_A, body=body) - self.assertEqual( - "Policy doesn't allow %s to be performed." % rule_name, - exc.format_message()) - mock_swap_volume.assert_not_called() - mock_bdm_save.assert_not_called() - - @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save') - @mock.patch('nova.compute.api.API.swap_volume') - def test_pass_swap_and_update_volume_attach_policy(self, - mock_swap_volume, - mock_bdm_save): - rule_name = self.policy_root % "swap" - req = fakes.HTTPRequest.blank('', version='2.85') - body = {'volumeAttachment': {'volumeId': FAKE_UUID_B, - 'delete_on_termination': True}} - self.common_policy_auth( - self.project_admin_authorized_contexts, - rule_name, self.controller.update, - req, FAKE_UUID, uuids.source_swap_vol, body=body) - mock_swap_volume.assert_called() - mock_bdm_save.assert_called() - - -class VolumeAttachNoLegacyNoScopePolicyTest(VolumeAttachPolicyTest): - """Test volume attachment APIs policies with no legacy deprecated rules - and no scope checks which means new defaults only. - - """ - - without_deprecated_rules = True - - def setUp(self): - super(VolumeAttachNoLegacyNoScopePolicyTest, self).setUp() - # With no legacy rule, only admin, member, or reader will be - # able to perform volume attachment operation on its own project. - self.project_member_authorized_contexts = ( - self.project_member_or_admin_with_no_scope_no_legacy) - self.project_reader_authorized_contexts = ( - self.project_reader_or_admin_with_no_scope_no_legacy) - - -class VolumeAttachScopeTypePolicyTest(VolumeAttachPolicyTest): - """Test os-volume-attachments APIs policies with system scope enabled. - - This class set the nova.conf [oslo_policy] enforce_scope to True - so that we can switch on the scope checking on oslo policy side. - It defines the set of context with scoped token - which are allowed and not allowed to pass the policy checks. - With those set of context, it will run the API operation and - verify the expected behaviour. - """ - - def setUp(self): - super(VolumeAttachScopeTypePolicyTest, self).setUp() - self.flags(enforce_scope=True, group="oslo_policy") - - # Scope enable will not allow system admin to perform the - # volume attachments. - self.project_member_authorized_contexts = ( - self.project_m_r_or_admin_with_scope_and_legacy) - self.project_reader_authorized_contexts = ( - self.project_m_r_or_admin_with_scope_and_legacy) - - self.project_admin_authorized_contexts = [ - self.legacy_admin_context, self.project_admin_context] - - -class VolumeAttachScopeTypeNoLegacyPolicyTest(VolumeAttachScopeTypePolicyTest): - """Test os-volume-attachments APIs policies with system scope enabled, - and no legacy deprecated rules. - """ - without_deprecated_rules = True - - def setUp(self): - super(VolumeAttachScopeTypeNoLegacyPolicyTest, self).setUp() - self.flags(enforce_scope=True, group="oslo_policy") - # With scope enable and no legacy rule, it will not allow - # system users and project admin/member/reader will be able to - # perform volume attachment operation on its own project. - self.project_member_authorized_contexts = ( - self.project_member_or_admin_with_scope_no_legacy) - self.project_reader_authorized_contexts = ( - self.project_reader_or_admin_with_scope_no_legacy) - class VolumesPolicyTest(base.BasePolicyTest): """Test Volumes APIs policies with all possible context. @@ -288,9 +31,8 @@ class VolumesPolicyTest(base.BasePolicyTest): """ def setUp(self): - super(VolumesPolicyTest, self).setUp() - self.controller = volumes_v21.VolumeController() - self.snapshot_ctlr = volumes_v21.SnapshotController() + super().setUp() + self.controller = volumes.VolumeController() self.req = fakes.HTTPRequest.blank('') self.controller._translate_volume_summary_view = mock.MagicMock() # Everyone will be able to perform crud operations @@ -364,42 +106,6 @@ class VolumesPolicyTest(base.BasePolicyTest): rule_name, self.controller.delete, self.req, uuids.fake_id) - @mock.patch('nova.volume.cinder.API.get_all_snapshots') - def test_list_snapshots_policy(self, mock_get): - rule_name = "os_compute_api:os-volumes:snapshots:list" - self.common_policy_auth(self.project_reader_authorized_contexts, - rule_name, self.snapshot_ctlr.index, - self.req) - - @mock.patch('nova.volume.cinder.API.get_all_snapshots') - def test_list_detail_snapshots_policy(self, mock_get): - rule_name = "os_compute_api:os-volumes:snapshots:detail" - self.common_policy_auth(self.project_reader_authorized_contexts, - rule_name, self.snapshot_ctlr.detail, - self.req) - - @mock.patch('nova.volume.cinder.API.get_snapshot') - def test_show_snapshot_policy(self, mock_get): - rule_name = "os_compute_api:os-volumes:snapshots:show" - self.common_policy_auth(self.project_reader_authorized_contexts, - rule_name, self.snapshot_ctlr.show, - self.req, uuids.fake_id) - - @mock.patch('nova.volume.cinder.API.create_snapshot') - def test_create_snapshot_policy(self, mock_create): - rule_name = "os_compute_api:os-volumes:snapshots:create" - body = {"snapshot": {"volume_id": uuids.fake_id}} - self.common_policy_auth(self.project_member_authorized_contexts, - rule_name, self.snapshot_ctlr.create, - self.req, body=body) - - @mock.patch('nova.volume.cinder.API.delete_snapshot') - def test_delete_snapshot_policy(self, mock_delete): - rule_name = "os_compute_api:os-volumes:snapshots:delete" - self.common_policy_auth(self.project_member_authorized_contexts, - rule_name, self.snapshot_ctlr.delete, - self.req, uuids.fake_id) - class VolumesNoLegacyNoScopePolicyTest(VolumesPolicyTest): """Test Volume APIs policies with no legacy deprecated rules @@ -432,7 +138,7 @@ class VolumesNoLegacyNoScopePolicyTest(VolumesPolicyTest): } def setUp(self): - super(VolumesNoLegacyNoScopePolicyTest, self).setUp() + super().setUp() # With no legacy, project other roles like foo will not be able # to operate on volume and snapshot. self.project_member_authorized_contexts = [ @@ -465,7 +171,7 @@ class VolumesScopeTypePolicyTest(VolumesPolicyTest): """ def setUp(self): - super(VolumesScopeTypePolicyTest, self).setUp() + super().setUp() self.flags(enforce_scope=True, group="oslo_policy") # With scope enabled, system users will not be able to # operate on volume and snapshot. @@ -519,7 +225,7 @@ class VolumesScopeTypeNoLegacyPolicyTest(VolumesScopeTypePolicyTest): } def setUp(self): - super(VolumesScopeTypeNoLegacyPolicyTest, self).setUp() + super().setUp() self.flags(enforce_scope=True, group="oslo_policy") # With no legacy and scope enabled, system users and project # other roles like foo will not be able to operate on volume