From 8ae6bc3697434aa2c8676afbff3d4938676a35a5 Mon Sep 17 00:00:00 2001 From: Adam Harwell Date: Tue, 12 Nov 2019 18:15:38 -0800 Subject: [PATCH] Availability Zone admin API Adds the ability for admins to create/manage availability_zones and profiles for use with upcoming functionality. Works like flavors. Depends-On: https://review.opendev.org/#/c/694057/ Change-Id: I468d9fdf8c9d0898f9e30f04ac233510a10a53fc --- api-ref/source/parameters.yaml | 72 +++ api-ref/source/v2/availabilityzone.inc | 290 +++++++++ api-ref/source/v2/availabilityzoneprofile.inc | 297 +++++++++ .../v2/examples/availabilityzone-create-curl | 1 + .../availabilityzone-create-request.json | 8 + .../availabilityzone-create-response.json | 8 + .../v2/examples/availabilityzone-delete-curl | 1 + .../v2/examples/availabilityzone-list-curl | 1 + .../availabilityzone-list-response.json | 10 + .../v2/examples/availabilityzone-show-curl | 1 + .../availabilityzone-show-response.json | 8 + .../v2/examples/availabilityzone-update-curl | 1 + .../availabilityzone-update-request.json | 6 + .../availabilityzone-update-response.json | 8 + .../availabilityzoneprofile-create-curl | 1 + ...vailabilityzoneprofile-create-request.json | 8 + ...ailabilityzoneprofile-create-response.json | 9 + .../availabilityzoneprofile-delete-curl | 1 + .../availabilityzoneprofile-list-curl | 1 + ...availabilityzoneprofile-list-response.json | 10 + .../availabilityzoneprofile-show-curl | 1 + ...availabilityzoneprofile-show-response.json | 9 + .../availabilityzoneprofile-update-curl | 1 + ...vailabilityzoneprofile-update-request.json | 8 + ...ailabilityzoneprofile-update-response.json | 9 + ...der-availability-zone-capability-show-curl | 1 + ...ability-zone-capability-show-response.json | 8 + api-ref/source/v2/flavor.inc | 2 +- api-ref/source/v2/flavorprofile.inc | 2 +- api-ref/source/v2/index.rst | 10 + api-ref/source/v2/provider.inc | 54 ++ .../availability_zone_schema.py | 47 ++ .../api/drivers/amphora_driver/v1/driver.py | 68 ++ .../api/drivers/amphora_driver/v2/driver.py | 68 ++ octavia/api/drivers/noop_driver/driver.py | 24 + octavia/api/root_controller.py | 5 +- octavia/api/v2/controllers/__init__.py | 6 + .../controllers/availability_zone_profiles.py | 237 +++++++ .../api/v2/controllers/availability_zones.py | 176 ++++++ octavia/api/v2/controllers/base.py | 17 + octavia/api/v2/controllers/provider.py | 61 +- .../api/v2/types/availability_zone_profile.py | 71 +++ octavia/api/v2/types/availability_zones.py | 69 ++ octavia/api/v2/types/provider.py | 4 + octavia/common/constants.py | 11 +- octavia/common/data_models.py | 20 + octavia/compute/compute_base.py | 10 + octavia/compute/drivers/noop_driver/driver.py | 9 + octavia/compute/drivers/nova_driver.py | 22 + octavia/db/base_models.py | 5 +- ...761c8a71579_add_availability_zone_table.py | 71 +++ octavia/db/models.py | 37 ++ octavia/db/repositories.py | 73 ++- octavia/policies/__init__.py | 6 + octavia/policies/availability_zone.py | 62 ++ octavia/policies/availability_zone_profile.py | 70 +++ octavia/policies/flavor.py | 2 +- octavia/policies/flavor_profile.py | 10 +- .../policies/provider_availability_zone.py | 33 + octavia/policies/provider_flavor.py | 2 +- .../functional/api/test_root_controller.py | 3 +- octavia/tests/functional/api/v2/base.py | 36 +- .../api/v2/test_availability_zone_profiles.py | 589 ++++++++++++++++++ .../api/v2/test_availability_zones.py | 581 +++++++++++++++++ .../functional/api/v2/test_flavor_profiles.py | 68 +- .../tests/functional/api/v2/test_flavors.py | 5 - .../tests/functional/api/v2/test_provider.py | 141 +++++ octavia/tests/functional/db/base.py | 12 + .../tests/functional/db/test_repositories.py | 3 +- .../amphora_driver/v1/test_amphora_driver.py | 42 ++ .../amphora_driver/v2/test_amphora_driver.py | 42 ++ .../api/drivers/test_provider_noop_driver.py | 17 + .../types/test_availability_zone_profiles.py | 70 +++ .../api/v2/types/test_availability_zones.py | 87 +++ .../drivers/test_compute_noop_driver.py | 13 + .../unit/compute/drivers/test_nova_driver.py | 17 +- ...vailability-zone-api-a28ff5e00bdcc69a.yaml | 6 + 77 files changed, 3817 insertions(+), 58 deletions(-) create mode 100644 api-ref/source/v2/availabilityzone.inc create mode 100644 api-ref/source/v2/availabilityzoneprofile.inc create mode 100644 api-ref/source/v2/examples/availabilityzone-create-curl create mode 100644 api-ref/source/v2/examples/availabilityzone-create-request.json create mode 100644 api-ref/source/v2/examples/availabilityzone-create-response.json create mode 100644 api-ref/source/v2/examples/availabilityzone-delete-curl create mode 100644 api-ref/source/v2/examples/availabilityzone-list-curl create mode 100644 api-ref/source/v2/examples/availabilityzone-list-response.json create mode 100644 api-ref/source/v2/examples/availabilityzone-show-curl create mode 100644 api-ref/source/v2/examples/availabilityzone-show-response.json create mode 100644 api-ref/source/v2/examples/availabilityzone-update-curl create mode 100644 api-ref/source/v2/examples/availabilityzone-update-request.json create mode 100644 api-ref/source/v2/examples/availabilityzone-update-response.json create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-create-curl create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-create-request.json create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-create-response.json create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-delete-curl create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-list-curl create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-list-response.json create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-show-curl create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-show-response.json create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-update-curl create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-update-request.json create mode 100644 api-ref/source/v2/examples/availabilityzoneprofile-update-response.json create mode 100644 api-ref/source/v2/examples/provider-availability-zone-capability-show-curl create mode 100644 api-ref/source/v2/examples/provider-availability-zone-capability-show-response.json create mode 100644 octavia/api/drivers/amphora_driver/availability_zone_schema.py create mode 100644 octavia/api/v2/controllers/availability_zone_profiles.py create mode 100644 octavia/api/v2/controllers/availability_zones.py create mode 100644 octavia/api/v2/types/availability_zone_profile.py create mode 100644 octavia/api/v2/types/availability_zones.py create mode 100644 octavia/db/migration/alembic_migrations/versions/c761c8a71579_add_availability_zone_table.py create mode 100644 octavia/policies/availability_zone.py create mode 100644 octavia/policies/availability_zone_profile.py create mode 100644 octavia/policies/provider_availability_zone.py create mode 100644 octavia/tests/functional/api/v2/test_availability_zone_profiles.py create mode 100644 octavia/tests/functional/api/v2/test_availability_zones.py create mode 100644 octavia/tests/unit/api/v2/types/test_availability_zone_profiles.py create mode 100644 octavia/tests/unit/api/v2/types/test_availability_zones.py create mode 100644 releasenotes/notes/availability-zone-api-a28ff5e00bdcc69a.yaml diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 3127352c3e..2e115f922d 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -7,6 +7,18 @@ path-amphora-id: in: path required: true type: uuid +path-availability-zone-name: + description: | + The name of the availability zone to query. + in: path + required: true + type: string +path-availability-zone-profile-id: + description: | + The ID of the availability zone profile to query. + in: path + required: true + type: uuid path-flavor-id: description: | The ID of the flavor to query. @@ -220,6 +232,66 @@ api_version_status: in: body required: true type: string +availability-zone: + description: | + An availability zone object. + in: body + required: true + type: object +availability-zone-capabilities: + description: | + The provider availability zone capabilities dictonary object. + in: body + required: true + type: object +availability-zone-capability-description: + description: | + The provider availability zone capability description. + in: body + required: true + type: string +availability-zone-capability-name: + description: | + The provider availability zone capability name. + in: body + required: true + type: string +availability-zone-data: + description: | + The JSON string containing the availability zone metadata. + in: body + required: true + type: string +availability-zone-data-optional: + description: | + The JSON string containing the availability zone metadata. + in: body + required: false + type: string +availability-zone-profile: + description: | + An ``availability zone profile`` object. + in: body + required: true + type: object +availability-zone-profile-id: + description: | + The ID of the availability zone profile. + in: body + required: true + type: uuid +availability-zone-profiles: + description: | + A list of ``availability zone profile`` objects. + in: body + required: true + type: array +availability-zones: + description: | + A list of ``availability zone`` objects. + in: body + required: true + type: array backup: description: | Is the member a backup? Backup members only receive traffic when all diff --git a/api-ref/source/v2/availabilityzone.inc b/api-ref/source/v2/availabilityzone.inc new file mode 100644 index 0000000000..1f80746b70 --- /dev/null +++ b/api-ref/source/v2/availabilityzone.inc @@ -0,0 +1,290 @@ +.. -*- rst -*- + +List Availability Zones +======================= + +.. rest_method:: GET /v2.0/lbaas/availabilityzones + +List all available availability zones. + +Use the ``fields`` query parameter to control which fields are +returned in the response body. Additionally, you can filter results +by using query string parameters. For information, see :ref:`filtering`. + +The list might be empty. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - fields: fields + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzone-list-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - description: description + - enabled: enabled + - availability_zone_profile_id: availability-zone-profile-id + - availability_zones: availability-zones + - name: name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzone-list-response.json + :language: javascript + +Create Availability Zone +======================== + +.. rest_method:: POST /v2.0/lbaas/availabilityzones + +Creates an availability zone. + +If the API cannot fulfill the request due to insufficient data or +data that is not valid, the service returns the HTTP ``Bad Request +(400)`` response code with information about the failure in the +response body. Validation errors require that you correct the error +and submit the request again. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 201 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 403 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - description: description-optional + - enabled: enabled-optional + - availability_zone: availability-zone + - availability_zone_profile_id: availability-zone-profile-id + - name: name + +Request Example +--------------- + +.. literalinclude:: examples/availabilityzone-create-request.json + :language: javascript + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzone-create-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - description: description + - enabled: enabled + - availability_zone_profile_id: availability-zone-profile-id + - availability_zone: availability-zone + - name: name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzone-create-response.json + :language: javascript + + +Show Availability Zone Details +============================== + +.. rest_method:: GET /v2.0/lbaas/availabilityzones/{availability_zone_name} + +Shows the details of an availability zone. + +Use the ``fields`` query parameter to control which fields are +returned in the response body. Additionally, you can filter results +by using query string parameters. For information, see :ref:`filtering`. + +This operation does not require a request body. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 401 + - 404 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - fields: fields + - availability_zone_name: path-availability-zone-name + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzone-show-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - description: description + - enabled: enabled + - availability_zone_profile_id: availability-zone-profile-id + - availability_zone: availability-zone + - name: name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzone-show-response.json + :language: javascript + +Update an Availability Zone +=========================== + +.. rest_method:: PUT /v2.0/lbaas/availabilityzones/{availability_zone_name} + +Update an availability zone. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 403 + - 404 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - description: description-optional + - enabled: enabled-optional + - availability_zone: availability-zone + - availability_zone_name: path-availability-zone-name + +Request Example +--------------- + +.. literalinclude:: examples/availabilityzone-update-request.json + :language: javascript + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzone-update-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - description: description + - enabled: enabled + - availability_zone_profile_id: availability-zone-profile-id + - availability_zone: availability-zone + - name: name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzone-update-response.json + :language: javascript + +Remove an Availability Zone +=========================== + +.. rest_method:: DELETE /v2.0/lbaas/availabilityzones/{availability_zone_name} + +Remove an availability zone and its associated configuration. + +If any load balancers are using this availability zone the service returns +the HTTP ``Conflict (409)`` response code. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 204 + +.. rest_status_code:: error ../http-status.yaml + + - 401 + - 403 + - 404 + - 409 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_name: path-availability-zone-name + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzone-delete-curl + :language: bash + +Response +-------- + +There is no body content for the response of a successful DELETE request. diff --git a/api-ref/source/v2/availabilityzoneprofile.inc b/api-ref/source/v2/availabilityzoneprofile.inc new file mode 100644 index 0000000000..632e1ec01a --- /dev/null +++ b/api-ref/source/v2/availabilityzoneprofile.inc @@ -0,0 +1,297 @@ +.. -*- rst -*- + +List Availability Zone Profiles +=============================== + +.. rest_method:: GET /v2.0/lbaas/availabilityzoneprofiles + +List all available Availability Zone Profiles. + +Use the ``fields`` query parameter to control which fields are +returned in the response body. Additionally, you can filter results +by using query string parameters. For information, see :ref:`filtering`. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +The list might be empty. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 403 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - fields: fields + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzoneprofile-list-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_data: availability-zone-data + - availability_zone_profiles: availability-zone-profiles + - id: availability-zone-profile-id + - name: name + - provider_name: provider-name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzoneprofile-list-response.json + :language: javascript + +Create Availability Zone Profile +================================ + +.. rest_method:: POST /v2.0/lbaas/availabilityzoneprofiles + +Creates a Availability Zone Profile. + +If the API cannot fulfill the request due to insufficient data or +data that is not valid, the service returns the HTTP ``Bad Request +(400)`` response code with information about the failure in the +response body. Validation errors require that you correct the error +and submit the request again. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 201 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 403 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_data: availability-zone-data + - availability_zone_profile: availability-zone-profile + - name: name + - provider_name: provider-name + +Request Example +--------------- + +.. literalinclude:: examples/availabilityzoneprofile-create-request.json + :language: javascript + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzoneprofile-create-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_data: availability-zone-data + - availability_zone_profile: availability-zone-profile + - id: availability-zone-profile-id + - name: name + - provider_name: provider-name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzoneprofile-create-response.json + :language: javascript + +Show Availability Zone Profile Details +====================================== + +.. rest_method:: GET /v2.0/lbaas/availabilityzoneprofiles/{availability_zone_profile_id} + +Shows the details of a Availability Zone Profile. + +Use the ``fields`` query parameter to control which fields are +returned in the response body. Additionally, you can filter results +by using query string parameters. For information, see :ref:`filtering`. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +This operation does not require a request body. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 401 + - 403 + - 404 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - fields: fields + - availability_zone_profile_id: path-availability-zone-profile-id + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzoneprofile-show-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_data: availability-zone-data + - availability_zone_profile: availability-zone-profile + - id: availability-zone-profile-id + - name: name + - provider_name: provider-name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzoneprofile-show-response.json + :language: javascript + +Update a Availability Zone Profile +================================== + +.. rest_method:: PUT /v2.0/lbaas/availabilityzoneprofiles/{availability_zone_profile_id} + +Update a Availability Zone Profile. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 403 + - 404 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_data: availability-zone-data-optional + - availability_zone_profile: availability-zone-profile + - availability_zone_profile_id: path-availability-zone-profile-id + - name: name-optional + - provider_name: provider-name-optional + +Request Example +--------------- + +.. literalinclude:: examples/availabilityzoneprofile-update-request.json + :language: javascript + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzoneprofile-update-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_data: availability-zone-data + - availability_zone_profile: availability-zone-profile + - id: availability-zone-profile-id + - name: name + - provider_name: provider-name + +Response Example +---------------- + +.. literalinclude:: examples/availabilityzoneprofile-update-response.json + :language: javascript + +Remove a Availability Zone Profile +================================== + +.. rest_method:: DELETE /v2.0/lbaas/availabilityzoneprofiles/{availability_zone_profile_id} + +Remove a Availability Zone Profile and its associated configuration. + +If any availability zone is using this Availability Zone Profile the service +returns the HTTP ``Conflict (409)`` response code. + +If you are not an administrative user the service returns the HTTP ``Forbidden +(403)`` response code. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 204 + +.. rest_status_code:: error ../http-status.yaml + + - 401 + - 403 + - 404 + - 409 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_profile_id: path-availability-zone-profile-id + +Curl Example +------------ + +.. literalinclude:: examples/availabilityzoneprofile-delete-curl + :language: bash + +Response +-------- + +There is no body content for the response of a successful DELETE request. diff --git a/api-ref/source/v2/examples/availabilityzone-create-curl b/api-ref/source/v2/examples/availabilityzone-create-curl new file mode 100644 index 0000000000..afe4680c66 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-create-curl @@ -0,0 +1 @@ +curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"availability_zone":{"name":"my_az","description":"My availability zone.","enabled":true,"availability_zone_profile_id":"5712097e-0092-45dc-bff0-ab68b61ad51a"}}' http://198.51.100.10:9876/v2.0/lbaas/availabilityzones diff --git a/api-ref/source/v2/examples/availabilityzone-create-request.json b/api-ref/source/v2/examples/availabilityzone-create-request.json new file mode 100644 index 0000000000..42a5f596a4 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-create-request.json @@ -0,0 +1,8 @@ +{ + "availability_zone": { + "name": "my_az", + "description": "My availability zone.", + "enabled": true, + "availability_zone_profile_id": "5712097e-0092-45dc-bff0-ab68b61ad51a" + } +} diff --git a/api-ref/source/v2/examples/availabilityzone-create-response.json b/api-ref/source/v2/examples/availabilityzone-create-response.json new file mode 100644 index 0000000000..42a5f596a4 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-create-response.json @@ -0,0 +1,8 @@ +{ + "availability_zone": { + "name": "my_az", + "description": "My availability zone.", + "enabled": true, + "availability_zone_profile_id": "5712097e-0092-45dc-bff0-ab68b61ad51a" + } +} diff --git a/api-ref/source/v2/examples/availabilityzone-delete-curl b/api-ref/source/v2/examples/availabilityzone-delete-curl new file mode 100644 index 0000000000..fcaad926a0 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-delete-curl @@ -0,0 +1 @@ +curl -X DELETE -H "X-Auth-Token: " http://198.51.100.10:9876/v2.0/lbaas/availabilityzones/my_az diff --git a/api-ref/source/v2/examples/availabilityzone-list-curl b/api-ref/source/v2/examples/availabilityzone-list-curl new file mode 100644 index 0000000000..fcca159a35 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-list-curl @@ -0,0 +1 @@ +curl -X GET -H "X-Auth-Token: " http://198.51.100.10:9876/v2.0/lbaas/availabilityzones diff --git a/api-ref/source/v2/examples/availabilityzone-list-response.json b/api-ref/source/v2/examples/availabilityzone-list-response.json new file mode 100644 index 0000000000..eac99f04cc --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-list-response.json @@ -0,0 +1,10 @@ +{ + "availability_zones": [ + { + "name": "my_az", + "description": "My availability zone.", + "enabled": true, + "availability_zone_profile_id": "5712097e-0092-45dc-bff0-ab68b61ad51a" + } + ] +} diff --git a/api-ref/source/v2/examples/availabilityzone-show-curl b/api-ref/source/v2/examples/availabilityzone-show-curl new file mode 100644 index 0000000000..baa2854daa --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-show-curl @@ -0,0 +1 @@ +curl -X GET -H "X-Auth-Token: " http://198.51.100.10:9876/v2.0/lbaas/availabilityzones/my_az diff --git a/api-ref/source/v2/examples/availabilityzone-show-response.json b/api-ref/source/v2/examples/availabilityzone-show-response.json new file mode 100644 index 0000000000..42a5f596a4 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-show-response.json @@ -0,0 +1,8 @@ +{ + "availability_zone": { + "name": "my_az", + "description": "My availability zone.", + "enabled": true, + "availability_zone_profile_id": "5712097e-0092-45dc-bff0-ab68b61ad51a" + } +} diff --git a/api-ref/source/v2/examples/availabilityzone-update-curl b/api-ref/source/v2/examples/availabilityzone-update-curl new file mode 100644 index 0000000000..063bd2732c --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-update-curl @@ -0,0 +1 @@ +curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"availability_zone":{"description":"My availability zone.","enabled":false}}' http://198.51.100.10:9876/v2.0/lbaas/availabilityzones/my_az diff --git a/api-ref/source/v2/examples/availabilityzone-update-request.json b/api-ref/source/v2/examples/availabilityzone-update-request.json new file mode 100644 index 0000000000..32b69dad5a --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-update-request.json @@ -0,0 +1,6 @@ +{ + "availability_zone": { + "description": "My availability zone.", + "enabled": false + } +} diff --git a/api-ref/source/v2/examples/availabilityzone-update-response.json b/api-ref/source/v2/examples/availabilityzone-update-response.json new file mode 100644 index 0000000000..5d7bbedbb1 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzone-update-response.json @@ -0,0 +1,8 @@ +{ + "availability_zone": { + "name": "my_az", + "description": "My availability zone.", + "enabled": false, + "availability_zone_profile_id": "5712097e-0092-45dc-bff0-ab68b61ad51a" + } +} diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-create-curl b/api-ref/source/v2/examples/availabilityzoneprofile-create-curl new file mode 100644 index 0000000000..98acd6ed82 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-create-curl @@ -0,0 +1 @@ +curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"availability_zone_profile":{"name":"some_az","provider_name":"amphora","availability_zone_data":"{\"compute_zone\": \"az1\"}"}}' http://198.51.100.10:9876/v2.0/lbaas/availabilityzoneprofiles diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-create-request.json b/api-ref/source/v2/examples/availabilityzoneprofile-create-request.json new file mode 100644 index 0000000000..ec81e81b33 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-create-request.json @@ -0,0 +1,8 @@ +{ + "availability_zone_profile": + { + "name": "some_az", + "provider_name": "amphora", + "availability_zone_data": "{\"compute_zone\": \"az1\"}" + } +} diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-create-response.json b/api-ref/source/v2/examples/availabilityzoneprofile-create-response.json new file mode 100644 index 0000000000..780837497d --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-create-response.json @@ -0,0 +1,9 @@ +{ + "availability_zone_profile": + { + "id": "5712097e-0092-45dc-bff0-ab68b61ad51a", + "name": "some_az", + "provider_name": "amphora", + "availability_zone_data": "{\"compute_zone\": \"az1\"}" + } +} diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-delete-curl b/api-ref/source/v2/examples/availabilityzoneprofile-delete-curl new file mode 100644 index 0000000000..c864b8fdd0 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-delete-curl @@ -0,0 +1 @@ +curl -X DELETE -H "X-Auth-Token: " http://198.51.100.10:9876/v2.0/lbaas/availabilityzoneprofiles/5712097e-0092-45dc-bff0-ab68b61ad51a diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-list-curl b/api-ref/source/v2/examples/availabilityzoneprofile-list-curl new file mode 100644 index 0000000000..a811706035 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-list-curl @@ -0,0 +1 @@ +curl -X GET -H "X-Auth-Token: " http://198.51.100.10:9876/v2.0/lbaas/availabilityzoneprofiles diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-list-response.json b/api-ref/source/v2/examples/availabilityzoneprofile-list-response.json new file mode 100644 index 0000000000..f666629f50 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-list-response.json @@ -0,0 +1,10 @@ +{ + "availability_zone_profiles": [ + { + "id": "5712097e-0092-45dc-bff0-ab68b61ad51a", + "name": "some_az", + "provider_name": "amphora", + "availability_zone_data": "{\"compute_zone\": \"az1\"}" + } + ] +} diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-show-curl b/api-ref/source/v2/examples/availabilityzoneprofile-show-curl new file mode 100644 index 0000000000..a768324033 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-show-curl @@ -0,0 +1 @@ +curl -X GET -H "X-Auth-Token: " http://198.51.100.10:9876/v2.0/lbaas/availabilityzoneprofiles/5712097e-0092-45dc-bff0-ab68b61ad51a diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-show-response.json b/api-ref/source/v2/examples/availabilityzoneprofile-show-response.json new file mode 100644 index 0000000000..780837497d --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-show-response.json @@ -0,0 +1,9 @@ +{ + "availability_zone_profile": + { + "id": "5712097e-0092-45dc-bff0-ab68b61ad51a", + "name": "some_az", + "provider_name": "amphora", + "availability_zone_data": "{\"compute_zone\": \"az1\"}" + } +} diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-update-curl b/api-ref/source/v2/examples/availabilityzoneprofile-update-curl new file mode 100644 index 0000000000..4d39fb8c56 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-update-curl @@ -0,0 +1 @@ +curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"availability_zone_profile":{"name":"other_az","provider_name":"amphora","availability_zone_data":"{\"compute_zone\": \"az2\"}"}}' http://198.51.100.10:9876/v2.0/lbaas/availabilityzoneprofiles/5712097e-0092-45dc-bff0-ab68b61ad51a diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-update-request.json b/api-ref/source/v2/examples/availabilityzoneprofile-update-request.json new file mode 100644 index 0000000000..0f0c7982e5 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-update-request.json @@ -0,0 +1,8 @@ +{ + "availability_zone_profile": + { + "name": "other_az", + "provider_name": "amphora", + "availability_zone_data": "{\"compute_zone\": \"az2\"}" + } +} diff --git a/api-ref/source/v2/examples/availabilityzoneprofile-update-response.json b/api-ref/source/v2/examples/availabilityzoneprofile-update-response.json new file mode 100644 index 0000000000..ac81a82623 --- /dev/null +++ b/api-ref/source/v2/examples/availabilityzoneprofile-update-response.json @@ -0,0 +1,9 @@ +{ + "availability_zone_profile": + { + "id": "5712097e-0092-45dc-bff0-ab68b61ad51a", + "name": "other_az", + "provider_name": "amphora", + "availability_zone_data": "{\"compute_zone\": \"az2\"}" + } +} diff --git a/api-ref/source/v2/examples/provider-availability-zone-capability-show-curl b/api-ref/source/v2/examples/provider-availability-zone-capability-show-curl new file mode 100644 index 0000000000..c89a067c00 --- /dev/null +++ b/api-ref/source/v2/examples/provider-availability-zone-capability-show-curl @@ -0,0 +1 @@ +curl -X GET -H "X-Auth-Token: " http://198.51.100.10:9876/v2/lbaas/providers/amphora/availability_zone_capabilities diff --git a/api-ref/source/v2/examples/provider-availability-zone-capability-show-response.json b/api-ref/source/v2/examples/provider-availability-zone-capability-show-response.json new file mode 100644 index 0000000000..e929e14a64 --- /dev/null +++ b/api-ref/source/v2/examples/provider-availability-zone-capability-show-response.json @@ -0,0 +1,8 @@ +{ + "availability_zone_capabilities": [ + { + "name": "compute_zone", + "description": "The compute availability zone." + } + ] +} diff --git a/api-ref/source/v2/flavor.inc b/api-ref/source/v2/flavor.inc index 335004bd9a..d13370b6ee 100644 --- a/api-ref/source/v2/flavor.inc +++ b/api-ref/source/v2/flavor.inc @@ -97,7 +97,7 @@ Request - name: name Request Example ----------------- +--------------- .. literalinclude:: examples/flavor-create-request.json :language: javascript diff --git a/api-ref/source/v2/flavorprofile.inc b/api-ref/source/v2/flavorprofile.inc index 27f7a09d68..c544e80d59 100644 --- a/api-ref/source/v2/flavorprofile.inc +++ b/api-ref/source/v2/flavorprofile.inc @@ -99,7 +99,7 @@ Request - provider_name: provider-name Request Example ----------------- +--------------- .. literalinclude:: examples/flavorprofile-create-request.json :language: javascript diff --git a/api-ref/source/v2/index.rst b/api-ref/source/v2/index.rst index 20acf575a4..a081c4b19a 100644 --- a/api-ref/source/v2/index.rst +++ b/api-ref/source/v2/index.rst @@ -66,6 +66,16 @@ Flavor Profiles --------------- .. include:: flavorprofile.inc +------------------ +Availability Zones +------------------ +.. include:: availabilityzone.inc + +-------------------------- +Availability Zone Profiles +-------------------------- +.. include:: availabilityzoneprofile.inc + -------- Amphorae -------- diff --git a/api-ref/source/v2/provider.inc b/api-ref/source/v2/provider.inc index 5a520b255c..c785e50613 100644 --- a/api-ref/source/v2/provider.inc +++ b/api-ref/source/v2/provider.inc @@ -103,3 +103,57 @@ Response Example .. literalinclude:: examples/provider-flavor-capability-show-response.json :language: javascript + +Show Provider Availability Zone Capabilities +============================================ + +.. rest_method:: GET /v2/lbaas/providers/{provider}/availability_zone_capabilities + +Shows the provider driver availability zone capabilities. These are the +features of the provider driver that can be configured in an Octavia +availability zone. This API returns a list of dictionaries with the name and +description of each availability zone capability of the provider. + +The list might be empty and a provider driver may not implement this feature. + +**New in version 2.14** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 403 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - fields: fields + - provider: path-provider + +Curl Example +------------ + +.. literalinclude:: examples/provider-availability-zone-capability-show-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - availability_zone_capabilities: availability-zone-capabilities + - name: availability-zone-capability-name + - description: availability-zone-capability-description + +Response Example +---------------- + +.. literalinclude:: examples/provider-availability-zone-capability-show-response.json + :language: javascript diff --git a/octavia/api/drivers/amphora_driver/availability_zone_schema.py b/octavia/api/drivers/amphora_driver/availability_zone_schema.py new file mode 100644 index 0000000000..7a019e2480 --- /dev/null +++ b/octavia/api/drivers/amphora_driver/availability_zone_schema.py @@ -0,0 +1,47 @@ +# Copyright 2018 Rackspace US Inc. All rights reserved. +# Copyright 2019 Verizon Media +# +# 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 octavia.common import constants as consts + +# This is a JSON schema validation dictionary +# https://json-schema.org/latest/json-schema-validation.html +# +# Note: This is used to generate the amphora driver "supported availability +# zone metadata" dictionary. Each property should include a description +# for the user to understand what this availability zone setting does. +# +# Where possible, the property name should match the configuration file name +# for the setting. The configuration file setting is the default when a +# setting is not defined in an availability zone profile. + +SUPPORTED_AVAILABILITY_ZONE_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Octavia Amphora Driver Availability Zone Metadata Schema", + "description": "This schema is used to validate new availability zone " + "profiles submitted for use in an amphora driver " + "availability zone.", + "type": "object", + "additionalProperties": False, + "properties": { + consts.COMPUTE_ZONE: { + "type": "string", + "description": "The compute availability zone." + }, + consts.MANAGEMENT_NETWORK: { + "type": "string", + "description": "The management network ID for the amphora." + } + } +} diff --git a/octavia/api/drivers/amphora_driver/v1/driver.py b/octavia/api/drivers/amphora_driver/v1/driver.py index de4d86da6a..830927eb7d 100644 --- a/octavia/api/drivers/amphora_driver/v1/driver.py +++ b/octavia/api/drivers/amphora_driver/v1/driver.py @@ -24,6 +24,7 @@ from octavia_lib.api.drivers import data_models as driver_dm from octavia_lib.api.drivers import exceptions from octavia_lib.api.drivers import provider_base as driver_base +from octavia.api.drivers.amphora_driver import availability_zone_schema from octavia.api.drivers.amphora_driver import flavor_schema from octavia.api.drivers import utils as driver_utils from octavia.common import constants as consts @@ -385,3 +386,70 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): # TODO(johnsom) Fix this to raise a NotFound error # when the octavia-lib supports it. compute_driver.validate_flavor(compute_flavor) + + # Availability Zone + def get_supported_availability_zone_metadata(self): + """Returns the valid availability zone metadata keys and descriptions. + + This extracts the valid availability zone metadata keys and + descriptions from the JSON validation schema and returns it as a + dictionary. + + :return: Dictionary of availability zone metadata keys and descriptions + :raises DriverError: An unexpected error occurred. + """ + try: + props = ( + availability_zone_schema.SUPPORTED_AVAILABILITY_ZONE_SCHEMA[ + 'properties']) + return {k: v.get('description', '') for k, v in props.items()} + except Exception as e: + raise exceptions.DriverError( + user_fault_string='Failed to get the supported availability ' + 'zone metadata due to: {}'.format(str(e)), + operator_fault_string='Failed to get the supported ' + 'availability zone metadata due to: ' + '{}'.format(str(e))) + + def validate_availability_zone(self, availability_zone_dict): + """Validates availability zone profile data. + + This will validate an availability zone profile dataset against the + availability zone settings the amphora driver supports. + + :param availability_zone_dict: The availability zone dict to validate. + :type availability_zone_dict: dict + :return: None + :raises DriverError: An unexpected error occurred. + :raises UnsupportedOptionError: If the driver does not support + one of the availability zone settings. + """ + try: + validate( + availability_zone_dict, + availability_zone_schema.SUPPORTED_AVAILABILITY_ZONE_SCHEMA) + except js_exceptions.ValidationError as e: + error_object = '' + if e.relative_path: + error_object = '{} '.format(e.relative_path[0]) + raise exceptions.UnsupportedOptionError( + user_fault_string='{0}{1}'.format(error_object, e.message), + operator_fault_string=str(e)) + except Exception as e: + raise exceptions.DriverError( + user_fault_string='Failed to validate the availability zone ' + 'metadata due to: {}'.format(str(e)), + operator_fault_string='Failed to validate the availability ' + 'zone metadata due to: {}'.format(str(e)) + ) + compute_zone = availability_zone_dict.get(consts.COMPUTE_ZONE, None) + if compute_zone: + compute_driver = stevedore_driver.DriverManager( + namespace='octavia.compute.drivers', + name=CONF.controller_worker.compute_driver, + invoke_on_load=True + ).driver + + # TODO(johnsom) Fix this to raise a NotFound error + # when the octavia-lib supports it. + compute_driver.validate_availability_zone(compute_zone) diff --git a/octavia/api/drivers/amphora_driver/v2/driver.py b/octavia/api/drivers/amphora_driver/v2/driver.py index ab7db428b6..134874ee6b 100644 --- a/octavia/api/drivers/amphora_driver/v2/driver.py +++ b/octavia/api/drivers/amphora_driver/v2/driver.py @@ -24,6 +24,7 @@ from octavia_lib.api.drivers import data_models as driver_dm from octavia_lib.api.drivers import exceptions from octavia_lib.api.drivers import provider_base as driver_base +from octavia.api.drivers.amphora_driver import availability_zone_schema from octavia.api.drivers.amphora_driver import flavor_schema from octavia.api.drivers import utils as driver_utils from octavia.common import constants as consts @@ -384,3 +385,70 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): # TODO(johnsom) Fix this to raise a NotFound error # when the octavia-lib supports it. compute_driver.validate_flavor(compute_flavor) + + # Availability Zone + def get_supported_availability_zone_metadata(self): + """Returns the valid availability zone metadata keys and descriptions. + + This extracts the valid availability zone metadata keys and + descriptions from the JSON validation schema and returns it as a + dictionary. + + :return: Dictionary of availability zone metadata keys and descriptions + :raises DriverError: An unexpected error occurred. + """ + try: + props = ( + availability_zone_schema.SUPPORTED_AVAILABILITY_ZONE_SCHEMA[ + 'properties']) + return {k: v.get('description', '') for k, v in props.items()} + except Exception as e: + raise exceptions.DriverError( + user_fault_string='Failed to get the supported availability ' + 'zone metadata due to: {}'.format(str(e)), + operator_fault_string='Failed to get the supported ' + 'availability zone metadata due to: ' + '{}'.format(str(e))) + + def validate_availability_zone(self, availability_zone_dict): + """Validates availability zone profile data. + + This will validate an availability zone profile dataset against the + availability zone settings the amphora driver supports. + + :param availability_zone_dict: The availability zone dict to validate. + :type availability_zone_dict: dict + :return: None + :raises DriverError: An unexpected error occurred. + :raises UnsupportedOptionError: If the driver does not support + one of the availability zone settings. + """ + try: + validate( + availability_zone_dict, + availability_zone_schema.SUPPORTED_AVAILABILITY_ZONE_SCHEMA) + except js_exceptions.ValidationError as e: + error_object = '' + if e.relative_path: + error_object = '{} '.format(e.relative_path[0]) + raise exceptions.UnsupportedOptionError( + user_fault_string='{0}{1}'.format(error_object, e.message), + operator_fault_string=str(e)) + except Exception as e: + raise exceptions.DriverError( + user_fault_string='Failed to validate the availability zone ' + 'metadata due to: {}'.format(str(e)), + operator_fault_string='Failed to validate the availability ' + 'zone metadata due to: {}'.format(str(e)) + ) + compute_zone = availability_zone_dict.get(consts.COMPUTE_ZONE, None) + if compute_zone: + compute_driver = stevedore_driver.DriverManager( + namespace='octavia.compute.drivers', + name=CONF.controller_worker.compute_driver, + invoke_on_load=True + ).driver + + # TODO(johnsom) Fix this to raise a NotFound error + # when the octavia-lib supports it. + compute_driver.validate_availability_zone(compute_zone) diff --git a/octavia/api/drivers/noop_driver/driver.py b/octavia/api/drivers/noop_driver/driver.py index 64943c4b6f..8eba116625 100644 --- a/octavia/api/drivers/noop_driver/driver.py +++ b/octavia/api/drivers/noop_driver/driver.py @@ -242,6 +242,23 @@ class NoopManager(object): flavor_hash = hash(frozenset(flavor_metadata)) self.driverconfig[flavor_hash] = (flavor_metadata, 'validate_flavor') + # Availability Zone + def get_supported_availability_zone_metadata(self): + LOG.debug( + 'Provider %s no-op, get_supported_availability_zone_metadata', + self.__class__.__name__) + + return {"compute_zone": "The compute availability zone to use for " + "this loadbalancer."} + + def validate_availability_zone(self, availability_zone_metadata): + LOG.debug('Provider %s no-op, validate_availability_zone metadata: %s', + self.__class__.__name__, availability_zone_metadata) + + availability_zone_hash = hash(frozenset(availability_zone_metadata)) + self.driverconfig[availability_zone_hash] = ( + availability_zone_metadata, 'validate_availability_zone') + class NoopProviderDriver(driver_base.ProviderDriver): def __init__(self): @@ -334,3 +351,10 @@ class NoopProviderDriver(driver_base.ProviderDriver): def validate_flavor(self, flavor_metadata): self.driver.validate_flavor(flavor_metadata) + + # Availability Zone + def get_supported_availability_zone_metadata(self): + return self.driver.get_supported_availability_zone_metadata() + + def validate_availability_zone(self, availability_zone_metadata): + self.driver.validate_availability_zone(availability_zone_metadata) diff --git a/octavia/api/root_controller.py b/octavia/api/root_controller.py index 68bbeaa461..37e344d9d7 100644 --- a/octavia/api/root_controller.py +++ b/octavia/api/root_controller.py @@ -88,6 +88,9 @@ class RootController(rest.RestController): self._add_a_version(versions, 'v2.12', 'v2', 'SUPPORTED', '2019-09-11T00:00:00Z', host_url) # SOURCE_IP_PORT algorithm - self._add_a_version(versions, 'v2.13', 'v2', 'CURRENT', + self._add_a_version(versions, 'v2.13', 'v2', 'SUPPORTED', '2019-09-13T00:00:00Z', host_url) + # Availability Zones + self._add_a_version(versions, 'v2.14', 'v2', 'CURRENT', + '2019-11-10T00:00:00Z', host_url) return {'versions': versions} diff --git a/octavia/api/v2/controllers/__init__.py b/octavia/api/v2/controllers/__init__.py index e5fb5f32c9..9184b957b3 100644 --- a/octavia/api/v2/controllers/__init__.py +++ b/octavia/api/v2/controllers/__init__.py @@ -16,6 +16,8 @@ from wsme import types as wtypes from wsmeext import pecan as wsme_pecan from octavia.api.v2.controllers import amphora +from octavia.api.v2.controllers import availability_zone_profiles +from octavia.api.v2.controllers import availability_zones from octavia.api.v2.controllers import base from octavia.api.v2.controllers import flavor_profiles from octavia.api.v2.controllers import flavors @@ -47,6 +49,10 @@ class BaseV2Controller(base.BaseController): self.providers = provider.ProviderController() self.flavors = flavors.FlavorsController() self.flavorprofiles = flavor_profiles.FlavorProfileController() + self.availabilityzones = ( + availability_zones.AvailabilityZonesController()) + self.availabilityzoneprofiles = ( + availability_zone_profiles.AvailabilityZoneProfileController()) @wsme_pecan.wsexpose(wtypes.text) def get(self): diff --git a/octavia/api/v2/controllers/availability_zone_profiles.py b/octavia/api/v2/controllers/availability_zone_profiles.py new file mode 100644 index 0000000000..24f87b166f --- /dev/null +++ b/octavia/api/v2/controllers/availability_zone_profiles.py @@ -0,0 +1,237 @@ +# Copyright 2019 Verizon Media +# +# 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 oslo_db import exception as odb_exceptions +from oslo_log import log as logging +from oslo_serialization import jsonutils +from oslo_utils import excutils +from oslo_utils import uuidutils +import pecan +from sqlalchemy.orm import exc as sa_exception +from wsme import types as wtypes +from wsmeext import pecan as wsme_pecan + +from octavia.api.drivers import driver_factory +from octavia.api.drivers import utils as driver_utils +from octavia.api.v2.controllers import base +from octavia.api.v2.types import availability_zone_profile as profile_types +from octavia.common import constants +from octavia.common import exceptions +from octavia.db import api as db_api + +LOG = logging.getLogger(__name__) + + +class AvailabilityZoneProfileController(base.BaseController): + RBAC_TYPE = constants.RBAC_AVAILABILITY_ZONE_PROFILE + + def __init__(self): + super(AvailabilityZoneProfileController, self).__init__() + + @wsme_pecan.wsexpose(profile_types.AvailabilityZoneProfileRootResponse, + wtypes.text, [wtypes.text], ignore_extra_args=True) + def get_one(self, id, fields=None): + """Gets an Availability Zone Profile's detail.""" + context = pecan.request.context.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_GET_ONE) + if id == constants.NIL_UUID: + raise exceptions.NotFound(resource='Availability Zone Profile', + id=constants.NIL_UUID) + db_availability_zone_profile = self._get_db_availability_zone_profile( + context.session, id) + result = self._convert_db_to_type( + db_availability_zone_profile, + profile_types.AvailabilityZoneProfileResponse) + if fields is not None: + result = self._filter_fields([result], fields)[0] + return profile_types.AvailabilityZoneProfileRootResponse( + availability_zone_profile=result) + + @wsme_pecan.wsexpose(profile_types.AvailabilityZoneProfilesRootResponse, + [wtypes.text], ignore_extra_args=True) + def get_all(self, fields=None): + """Lists all Availability Zone Profiles.""" + pcontext = pecan.request.context + context = pcontext.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_GET_ALL) + db_availability_zone_profiles, links = ( + self.repositories.availability_zone_profile.get_all( + context.session, + pagination_helper=pcontext.get(constants.PAGINATION_HELPER))) + result = self._convert_db_to_type( + db_availability_zone_profiles, + [profile_types.AvailabilityZoneProfileResponse]) + if fields is not None: + result = self._filter_fields(result, fields) + return profile_types.AvailabilityZoneProfilesRootResponse( + availability_zone_profiles=result, + availability_zone_profile_links=links) + + @wsme_pecan.wsexpose(profile_types.AvailabilityZoneProfileRootResponse, + body=profile_types.AvailabilityZoneProfileRootPOST, + status_code=201) + def post(self, availability_zone_profile_): + """Creates an Availability Zone Profile.""" + availability_zone_profile = ( + availability_zone_profile_.availability_zone_profile) + context = pecan.request.context.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_POST) + # Do a basic JSON validation on the metadata + try: + availability_zone_data_dict = jsonutils.loads( + availability_zone_profile.availability_zone_data) + except Exception: + raise exceptions.InvalidOption( + value=availability_zone_profile.availability_zone_data, + option=constants.AVAILABILITY_ZONE_DATA) + + # Validate that the provider driver supports the metadata + driver = driver_factory.get_driver( + availability_zone_profile.provider_name) + driver_utils.call_provider( + driver.name, driver.validate_availability_zone, + availability_zone_data_dict) + + lock_session = db_api.get_session(autocommit=False) + try: + availability_zone_profile_dict = availability_zone_profile.to_dict( + render_unsets=True) + availability_zone_profile_dict['id'] = uuidutils.generate_uuid() + db_availability_zone_profile = ( + self.repositories.availability_zone_profile.create( + lock_session, **availability_zone_profile_dict)) + lock_session.commit() + except odb_exceptions.DBDuplicateEntry: + lock_session.rollback() + raise exceptions.IDAlreadyExists() + except Exception: + with excutils.save_and_reraise_exception(): + lock_session.rollback() + result = self._convert_db_to_type( + db_availability_zone_profile, + profile_types.AvailabilityZoneProfileResponse) + return profile_types.AvailabilityZoneProfileRootResponse( + availability_zone_profile=result) + + def _validate_update_azp(self, context, id, availability_zone_profile): + if availability_zone_profile.name is None: + raise exceptions.InvalidOption(value=None, option=constants.NAME) + if availability_zone_profile.provider_name is None: + raise exceptions.InvalidOption( + value=None, option=constants.PROVIDER_NAME) + if availability_zone_profile.availability_zone_data is None: + raise exceptions.InvalidOption( + value=None, option=constants.AVAILABILITY_ZONE_DATA) + + # Don't allow changes to the availability_zone_data or provider_name if + # it is in use. + if (not isinstance(availability_zone_profile.availability_zone_data, + wtypes.UnsetType) or + not isinstance(availability_zone_profile.provider_name, + wtypes.UnsetType)): + if self.repositories.availability_zone.count( + context.session, availability_zone_profile_id=id) > 0: + raise exceptions.ObjectInUse( + object='Availability Zone Profile', id=id) + + @wsme_pecan.wsexpose(profile_types.AvailabilityZoneProfileRootResponse, + wtypes.text, status_code=200, + body=profile_types.AvailabilityZoneProfileRootPUT) + def put(self, id, availability_zone_profile_): + """Updates an Availability Zone Profile.""" + availability_zone_profile = ( + availability_zone_profile_.availability_zone_profile) + context = pecan.request.context.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_PUT) + + self._validate_update_azp(context, id, availability_zone_profile) + if id == constants.NIL_UUID: + raise exceptions.NotFound(resource='Availability Zone Profile', + id=constants.NIL_UUID) + + if not isinstance(availability_zone_profile.availability_zone_data, + wtypes.UnsetType): + # Do a basic JSON validation on the metadata + try: + availability_zone_data_dict = jsonutils.loads( + availability_zone_profile.availability_zone_data) + except Exception: + raise exceptions.InvalidOption( + value=availability_zone_profile.availability_zone_data, + option=constants.FLAVOR_DATA) + + if isinstance(availability_zone_profile.provider_name, + wtypes.UnsetType): + db_availability_zone_profile = ( + self._get_db_availability_zone_profile( + context.session, id)) + provider_driver = db_availability_zone_profile.provider_name + else: + provider_driver = availability_zone_profile.provider_name + + # Validate that the provider driver supports the metadata + driver = driver_factory.get_driver(provider_driver) + driver_utils.call_provider( + driver.name, driver.validate_availability_zone, + availability_zone_data_dict) + + lock_session = db_api.get_session(autocommit=False) + try: + availability_zone_profile_dict = availability_zone_profile.to_dict( + render_unsets=False) + if availability_zone_profile_dict: + self.repositories.availability_zone_profile.update( + lock_session, id, **availability_zone_profile_dict) + lock_session.commit() + except Exception: + with excutils.save_and_reraise_exception(): + lock_session.rollback() + + # Force SQL alchemy to query the DB, otherwise we get inconsistent + # results + context.session.expire_all() + db_availability_zone_profile = self._get_db_availability_zone_profile( + context.session, id) + result = self._convert_db_to_type( + db_availability_zone_profile, + profile_types.AvailabilityZoneProfileResponse) + return profile_types.AvailabilityZoneProfileRootResponse( + availability_zone_profile=result) + + @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) + def delete(self, availability_zone_profile_id): + """Deletes an Availability Zone Profile""" + context = pecan.request.context.get('octavia_context') + + self._auth_validate_action(context, context.project_id, + constants.RBAC_DELETE) + if availability_zone_profile_id == constants.NIL_UUID: + raise exceptions.NotFound(resource='Availability Zone Profile', + id=constants.NIL_UUID) + # Don't allow it to be deleted if it is in use by an availability zone + if self.repositories.availability_zone.count( + context.session, + availability_zone_profile_id=availability_zone_profile_id) > 0: + raise exceptions.ObjectInUse(object='Availability Zone Profile', + id=availability_zone_profile_id) + try: + self.repositories.availability_zone_profile.delete( + context.session, id=availability_zone_profile_id) + except sa_exception.NoResultFound: + raise exceptions.NotFound(resource='Availability Zone Profile', + id=availability_zone_profile_id) diff --git a/octavia/api/v2/controllers/availability_zones.py b/octavia/api/v2/controllers/availability_zones.py new file mode 100644 index 0000000000..0709f7b483 --- /dev/null +++ b/octavia/api/v2/controllers/availability_zones.py @@ -0,0 +1,176 @@ +# Copyright 2019 Verizon Media +# +# 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 oslo_db import api as oslo_db_api +from oslo_db import exception as odb_exceptions +from oslo_log import log as logging +from oslo_utils import excutils +import pecan +from sqlalchemy.orm import exc as sa_exception +from wsme import types as wtypes +from wsmeext import pecan as wsme_pecan + +from octavia.api.v2.controllers import base +from octavia.api.v2.types import availability_zones as availability_zone_types +from octavia.common import constants +from octavia.common import exceptions +from octavia.db import api as db_api + +LOG = logging.getLogger(__name__) + + +class AvailabilityZonesController(base.BaseController): + RBAC_TYPE = constants.RBAC_AVAILABILITY_ZONE + + def __init__(self): + super(AvailabilityZonesController, self).__init__() + + @wsme_pecan.wsexpose(availability_zone_types.AvailabilityZoneRootResponse, + wtypes.text, [wtypes.text], ignore_extra_args=True) + def get_one(self, name, fields=None): + """Gets an Availability Zone's detail.""" + context = pecan.request.context.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_GET_ONE) + if name == constants.NIL_UUID: + raise exceptions.NotFound(resource='Availability Zone', + id=constants.NIL_UUID) + db_availability_zone = self._get_db_availability_zone( + context.session, name) + result = self._convert_db_to_type( + db_availability_zone, + availability_zone_types.AvailabilityZoneResponse) + if fields is not None: + result = self._filter_fields([result], fields)[0] + return availability_zone_types.AvailabilityZoneRootResponse( + availability_zone=result) + + @wsme_pecan.wsexpose(availability_zone_types.AvailabilityZonesRootResponse, + [wtypes.text], ignore_extra_args=True) + def get_all(self, fields=None): + """Lists all Availability Zones.""" + pcontext = pecan.request.context + context = pcontext.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_GET_ALL) + db_availability_zones, links = ( + self.repositories.availability_zone.get_all( + context.session, + pagination_helper=pcontext.get(constants.PAGINATION_HELPER))) + result = self._convert_db_to_type( + db_availability_zones, + [availability_zone_types.AvailabilityZoneResponse]) + if fields is not None: + result = self._filter_fields(result, fields) + return availability_zone_types.AvailabilityZonesRootResponse( + availability_zones=result, availability_zones_links=links) + + @wsme_pecan.wsexpose(availability_zone_types.AvailabilityZoneRootResponse, + body=availability_zone_types.AvailabilityZoneRootPOST, + status_code=201) + def post(self, availability_zone_): + """Creates an Availability Zone.""" + availability_zone = availability_zone_.availability_zone + context = pecan.request.context.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_POST) + + lock_session = db_api.get_session(autocommit=False) + try: + availability_zone_dict = availability_zone.to_dict( + render_unsets=True) + db_availability_zone = self.repositories.availability_zone.create( + lock_session, **availability_zone_dict) + lock_session.commit() + except odb_exceptions.DBDuplicateEntry: + lock_session.rollback() + raise exceptions.RecordAlreadyExists(field='availability zone', + name=availability_zone.name) + except Exception: + with excutils.save_and_reraise_exception(): + lock_session.rollback() + result = self._convert_db_to_type( + db_availability_zone, + availability_zone_types.AvailabilityZoneResponse) + return availability_zone_types.AvailabilityZoneRootResponse( + availability_zone=result) + + @wsme_pecan.wsexpose(availability_zone_types.AvailabilityZoneRootResponse, + wtypes.text, status_code=200, + body=availability_zone_types.AvailabilityZoneRootPUT) + def put(self, name, availability_zone_): + availability_zone = availability_zone_.availability_zone + context = pecan.request.context.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_PUT) + if name == constants.NIL_UUID: + raise exceptions.NotFound(resource='Availability Zone', + id=constants.NIL_UUID) + lock_session = db_api.get_session(autocommit=False) + try: + availability_zone_dict = availability_zone.to_dict( + render_unsets=False) + if availability_zone_dict: + self.repositories.availability_zone.update( + lock_session, name, **availability_zone_dict) + lock_session.commit() + except Exception: + with excutils.save_and_reraise_exception(): + lock_session.rollback() + + # Force SQL alchemy to query the DB, otherwise we get inconsistent + # results + context.session.expire_all() + db_availability_zone = self._get_db_availability_zone( + context.session, name) + result = self._convert_db_to_type( + db_availability_zone, + availability_zone_types.AvailabilityZoneResponse) + return availability_zone_types.AvailabilityZoneRootResponse( + availability_zone=result) + + @oslo_db_api.wrap_db_retry(max_retries=5, retry_on_deadlock=True) + @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) + def delete(self, availability_zone_name): + """Deletes an Availability Zone""" + context = pecan.request.context.get('octavia_context') + + self._auth_validate_action(context, context.project_id, + constants.RBAC_DELETE) + if availability_zone_name == constants.NIL_UUID: + raise exceptions.NotFound(resource='Availability Zone', + id=constants.NIL_UUID) + serial_session = db_api.get_session(autocommit=False) + serial_session.connection( + execution_options={'isolation_level': 'SERIALIZABLE'}) + try: + self.repositories.availability_zone.delete( + serial_session, name=availability_zone_name) + serial_session.commit() + # Handle when load balancers still reference this availability_zone + except odb_exceptions.DBReferenceError: + serial_session.rollback() + raise exceptions.ObjectInUse(object='Availability Zone', + id=availability_zone_name) + except sa_exception.NoResultFound: + serial_session.rollback() + raise exceptions.NotFound(resource='Availability Zone', + id=availability_zone_name) + except Exception as e: + with excutils.save_and_reraise_exception(): + LOG.error( + 'Unknown availability_zone delete exception: %s', str(e)) + serial_session.rollback() + finally: + serial_session.close() diff --git a/octavia/api/v2/controllers/base.py b/octavia/api/v2/controllers/base.py index 60b334ff17..e60ccbd1d7 100644 --- a/octavia/api/v2/controllers/base.py +++ b/octavia/api/v2/controllers/base.py @@ -119,6 +119,23 @@ class BaseController(pecan.rest.RestController): return self._get_db_obj(session, self.repositories.flavor_profile, data_models.FlavorProfile, id) + def _get_db_availability_zone(self, session, name): + """Get an availability zone from the database.""" + db_obj = self.repositories.availability_zone.get(session, name=name) + if not db_obj: + LOG.debug('%(obj_name)s %(name)s not found', + {'obj_name': data_models.AvailabilityZone._name(), + 'name': name}) + raise exceptions.NotFound( + resource=data_models.AvailabilityZone._name(), id=name) + return db_obj + + def _get_db_availability_zone_profile(self, session, id): + """Get an availability zone profile from the database.""" + return self._get_db_obj(session, + self.repositories.availability_zone_profile, + data_models.AvailabilityZoneProfile, id) + def _get_db_l7policy(self, session, id, show_deleted=True): """Get a L7 Policy from the database.""" return self._get_db_obj(session, self.repositories.l7policy, diff --git a/octavia/api/v2/controllers/provider.py b/octavia/api/v2/controllers/provider.py index 29ce8e96f2..e70e73061d 100644 --- a/octavia/api/v2/controllers/provider.py +++ b/octavia/api/v2/controllers/provider.py @@ -61,8 +61,13 @@ class ProviderController(base.BaseController): Currently it checks if this was a flavor capabilities request and routes the request to the FlavorCapabilitiesController. """ - if provider and remainder and remainder[0] == 'flavor_capabilities': - return (FlavorCapabilitiesController(provider=provider), + if provider and remainder: + if remainder[0] == 'flavor_capabilities': + return (FlavorCapabilitiesController(provider=provider), + remainder[1:]) + if remainder[0] == 'availability_zone_capabilities': + return ( + AvailabilityZoneCapabilitiesController(provider=provider), remainder[1:]) return None @@ -115,3 +120,55 @@ class FlavorCapabilitiesController(base.BaseController): response_list = self._filter_fields(response_list, fields) return provider_types.FlavorCapabilitiesResponse( flavor_capabilities=response_list) + + +class AvailabilityZoneCapabilitiesController(base.BaseController): + RBAC_TYPE = constants.RBAC_PROVIDER_AVAILABILITY_ZONE + + def __init__(self, provider): + super(AvailabilityZoneCapabilitiesController, self).__init__() + self.provider = provider + + @wsme_pecan.wsexpose(provider_types.AvailabilityZoneCapabilitiesResponse, + [wtypes.text], ignore_extra_args=True, + status_code=200) + def get_all(self, fields=None): + context = pecan.request.context.get('octavia_context') + self._auth_validate_action(context, context.project_id, + constants.RBAC_GET_ALL) + self.driver = driver_factory.get_driver(self.provider) + try: + metadata_dict = ( + self.driver.get_supported_availability_zone_metadata()) + except driver_except.NotImplementedError as e: + LOG.warning( + 'Provider %s get_supported_availability_zone_metadata() ' + 'reported: %s', self.provider, e.operator_fault_string) + raise exceptions.ProviderNotImplementedError( + prov=self.provider, user_msg=e.user_fault_string) + + # Apply any valid filters provided as URL parameters + name_filter = None + description_filter = None + pagination_helper = pecan.request.context.get( + constants.PAGINATION_HELPER) + if pagination_helper: + name_filter = pagination_helper.params.get(constants.NAME) + description_filter = pagination_helper.params.get( + constants.DESCRIPTION) + if name_filter: + metadata_dict = { + key: value for key, value in six.iteritems(metadata_dict) if + key == name_filter} + if description_filter: + metadata_dict = { + key: value for key, value in six.iteritems(metadata_dict) if + value == description_filter} + + response_list = [ + provider_types.ProviderResponse(name=key, description=value) for + key, value in six.iteritems(metadata_dict)] + if fields is not None: + response_list = self._filter_fields(response_list, fields) + return provider_types.AvailabilityZoneCapabilitiesResponse( + availability_zone_capabilities=response_list) diff --git a/octavia/api/v2/types/availability_zone_profile.py b/octavia/api/v2/types/availability_zone_profile.py new file mode 100644 index 0000000000..a423389e60 --- /dev/null +++ b/octavia/api/v2/types/availability_zone_profile.py @@ -0,0 +1,71 @@ +# Copyright 2019 Verizon Media +# +# 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 wsme import types as wtypes + +from octavia.api.common import types + + +class BaseAvailabilityZoneProfileType(types.BaseType): + _type_to_model_map = {} + _child_map = {} + + +class AvailabilityZoneProfileResponse(BaseAvailabilityZoneProfileType): + """Defines which attributes are to be shown on any response.""" + id = wtypes.wsattr(wtypes.UuidType()) + name = wtypes.wsattr(wtypes.StringType()) + provider_name = wtypes.wsattr(wtypes.StringType()) + availability_zone_data = wtypes.wsattr(wtypes.StringType()) + + @classmethod + def from_data_model(cls, data_model, children=False): + availability_zone_profile = super( + AvailabilityZoneProfileResponse, cls).from_data_model( + data_model, children=children) + return availability_zone_profile + + +class AvailabilityZoneProfileRootResponse(types.BaseType): + availability_zone_profile = wtypes.wsattr(AvailabilityZoneProfileResponse) + + +class AvailabilityZoneProfilesRootResponse(types.BaseType): + availability_zone_profiles = wtypes.wsattr( + [AvailabilityZoneProfileResponse]) + availability_zone_profile_links = wtypes.wsattr([types.PageType]) + + +class AvailabilityZoneProfilePOST(BaseAvailabilityZoneProfileType): + """Defines mandatory and optional attributes of a POST request.""" + name = wtypes.wsattr(wtypes.StringType(max_length=255), mandatory=True) + provider_name = wtypes.wsattr(wtypes.StringType(max_length=255), + mandatory=True) + availability_zone_data = wtypes.wsattr(wtypes.StringType(max_length=4096), + mandatory=True) + + +class AvailabilityZoneProfileRootPOST(types.BaseType): + availability_zone_profile = wtypes.wsattr(AvailabilityZoneProfilePOST) + + +class AvailabilityZoneProfilePUT(BaseAvailabilityZoneProfileType): + """Defines the attributes of a PUT request.""" + name = wtypes.wsattr(wtypes.StringType(max_length=255)) + provider_name = wtypes.wsattr(wtypes.StringType(max_length=255)) + availability_zone_data = wtypes.wsattr(wtypes.StringType(max_length=4096)) + + +class AvailabilityZoneProfileRootPUT(types.BaseType): + availability_zone_profile = wtypes.wsattr(AvailabilityZoneProfilePUT) diff --git a/octavia/api/v2/types/availability_zones.py b/octavia/api/v2/types/availability_zones.py new file mode 100644 index 0000000000..c56fe01f90 --- /dev/null +++ b/octavia/api/v2/types/availability_zones.py @@ -0,0 +1,69 @@ +# Copyright 2019 Verizon Media +# +# 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 wsme import types as wtypes + +from octavia.api.common import types + + +class BaseAvailabilityZoneType(types.BaseType): + _type_to_model_map = {} + _child_map = {} + + +class AvailabilityZoneResponse(BaseAvailabilityZoneType): + """Defines which attributes are to be shown on any response.""" + name = wtypes.wsattr(wtypes.StringType()) + description = wtypes.wsattr(wtypes.StringType()) + enabled = wtypes.wsattr(bool) + availability_zone_profile_id = wtypes.wsattr(wtypes.StringType()) + + @classmethod + def from_data_model(cls, data_model, children=False): + availability_zone = super( + AvailabilityZoneResponse, cls).from_data_model( + data_model, children=children) + return availability_zone + + +class AvailabilityZoneRootResponse(types.BaseType): + availability_zone = wtypes.wsattr(AvailabilityZoneResponse) + + +class AvailabilityZonesRootResponse(types.BaseType): + availability_zones = wtypes.wsattr([AvailabilityZoneResponse]) + availability_zones_links = wtypes.wsattr([types.PageType]) + + +class AvailabilityZonePOST(BaseAvailabilityZoneType): + """Defines mandatory and optional attributes of a POST request.""" + name = wtypes.wsattr(wtypes.StringType(max_length=255), mandatory=True) + description = wtypes.wsattr(wtypes.StringType(max_length=255)) + enabled = wtypes.wsattr(bool, default=True) + availability_zone_profile_id = wtypes.wsattr(wtypes.UuidType(), + mandatory=True) + + +class AvailabilityZoneRootPOST(types.BaseType): + availability_zone = wtypes.wsattr(AvailabilityZonePOST) + + +class AvailabilityZonePUT(BaseAvailabilityZoneType): + """Defines the attributes of a PUT request.""" + description = wtypes.wsattr(wtypes.StringType(max_length=255)) + enabled = wtypes.wsattr(bool) + + +class AvailabilityZoneRootPUT(types.BaseType): + availability_zone = wtypes.wsattr(AvailabilityZonePUT) diff --git a/octavia/api/v2/types/provider.py b/octavia/api/v2/types/provider.py index ea1e79880c..5a167115af 100644 --- a/octavia/api/v2/types/provider.py +++ b/octavia/api/v2/types/provider.py @@ -28,3 +28,7 @@ class ProvidersRootResponse(types.BaseType): class FlavorCapabilitiesResponse(types.BaseType): flavor_capabilities = wtypes.wsattr([ProviderResponse]) + + +class AvailabilityZoneCapabilitiesResponse(types.BaseType): + availability_zone_capabilities = wtypes.wsattr([ProviderResponse]) diff --git a/octavia/common/constants.py b/octavia/common/constants.py index 2230c78e8a..85b734aa48 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -302,6 +302,7 @@ CLIENT_CA_TLS_CERTIFICATE_ID = 'client_ca_tls_certificate_id' CLIENT_CRL_CONTAINER_ID = 'client_crl_container_id' COMPUTE_ID = 'compute_id' COMPUTE_OBJ = 'compute_obj' +COMPUTE_ZONE = 'compute_zone' CONN_MAX_RETRIES = 'conn_max_retries' CONN_RETRY_INTERVAL = 'conn_retry_interval' CREATED_AT = 'created_at' @@ -333,6 +334,7 @@ LOADBALANCER = 'loadbalancer' LOADBALANCER_ID = 'loadbalancer_id' LOAD_BALANCER_ID = 'load_balancer_id' LOAD_BALANCER_UPDATES = 'load_balancer_updates' +MANAGEMENT_NETWORK = 'management_network' MEMBER = 'member' MEMBER_ID = 'member_id' MEMBER_PORTS = 'member_ports' @@ -648,8 +650,13 @@ RBAC_QUOTA = '{}:quota:'.format(LOADBALANCER_API) RBAC_AMPHORA = '{}:amphora:'.format(LOADBALANCER_API) RBAC_PROVIDER = '{}:provider:'.format(LOADBALANCER_API) RBAC_PROVIDER_FLAVOR = '{}:provider-flavor:'.format(LOADBALANCER_API) +RBAC_PROVIDER_AVAILABILITY_ZONE = '{}:provider-availability-zone:'.format( + LOADBALANCER_API) RBAC_FLAVOR = '{}:flavor:'.format(LOADBALANCER_API) RBAC_FLAVOR_PROFILE = '{}:flavor-profile:'.format(LOADBALANCER_API) +RBAC_AVAILABILITY_ZONE = '{}:availability-zone:'.format(LOADBALANCER_API) +RBAC_AVAILABILITY_ZONE_PROFILE = '{}:availability-zone-profile:'.format( + LOADBALANCER_API) RBAC_POST = 'post' RBAC_PUT = 'put' RBAC_PUT_CONFIG = 'put_config' @@ -675,10 +682,12 @@ AMP_NETNS_SVC_PREFIX = 'amphora-netns' # Amphora Feature Compatibility HTTP_REUSE = 'has_http_reuse' -# TODO(johnsom) convert this to octavia_lib constant flavor +# TODO(johnsom) convert these to octavia_lib constants # once octavia is transitioned to use octavia_lib FLAVOR = 'flavor' FLAVOR_DATA = 'flavor_data' +AVAILABILITY_ZONE = 'availability_zone' +AVAILABILITY_ZONE_DATA = 'availability_zone_data' # Flavor metadata LOADBALANCER_TOPOLOGY = 'loadbalancer_topology' diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py index 0756d685f8..22cebc9399 100644 --- a/octavia/common/data_models.py +++ b/octavia/common/data_models.py @@ -781,6 +781,26 @@ class FlavorProfile(BaseDataModel): self.flavor_data = flavor_data +class AvailabilityZone(BaseDataModel): + + def __init__(self, name=None, description=None, enabled=None, + availability_zone_profile_id=None): + self.name = name + self.description = description + self.enabled = enabled + self.availability_zone_profile_id = availability_zone_profile_id + + +class AvailabilityZoneProfile(BaseDataModel): + + def __init__(self, id=None, name=None, provider_name=None, + availability_zone_data=None): + self.id = id + self.name = name + self.provider_name = provider_name + self.availability_zone_data = availability_zone_data + + class ListenerCidr(BaseDataModel): def __init__(self, listener_id=None, cidr=None): diff --git a/octavia/compute/compute_base.py b/octavia/compute/compute_base.py index 71088aedd9..2b2db1bedd 100644 --- a/octavia/compute/compute_base.py +++ b/octavia/compute/compute_base.py @@ -123,3 +123,13 @@ class ComputeBase(object): :raises: NotFound :raises: NotImplementedError """ + + @abc.abstractmethod + def validate_availability_zone(self, availability_zone): + """Validates that a compute availability zone exists. + + :param availability_zone: Name of the compute availability zone. + :return: None + :raises: NotFound + :raises: NotImplementedError + """ diff --git a/octavia/compute/drivers/noop_driver/driver.py b/octavia/compute/drivers/noop_driver/driver.py index 6683529e0d..23d2e0f274 100644 --- a/octavia/compute/drivers/noop_driver/driver.py +++ b/octavia/compute/drivers/noop_driver/driver.py @@ -111,6 +111,12 @@ class NoopManager(object): self.__class__.__name__, flavor_id) self.computeconfig[flavor_id] = (flavor_id, 'validate_flavor') + def validate_availability_zone(self, availability_zone): + LOG.debug("Compute %s no-op, validate_availability_zone name %s", + self.__class__.__name__, availability_zone) + self.computeconfig[availability_zone] = ( + availability_zone, 'validate_availability_zone') + class NoopComputeDriver(driver_base.ComputeBase): def __init__(self): @@ -155,3 +161,6 @@ class NoopComputeDriver(driver_base.ComputeBase): def validate_flavor(self, flavor_id): self.driver.validate_flavor(flavor_id) + + def validate_availability_zone(self, availability_zone): + self.driver.validate_availability_zone(availability_zone) diff --git a/octavia/compute/drivers/nova_driver.py b/octavia/compute/drivers/nova_driver.py index 97ff905c60..8e7bfd0866 100644 --- a/octavia/compute/drivers/nova_driver.py +++ b/octavia/compute/drivers/nova_driver.py @@ -89,6 +89,7 @@ class VirtualMachineManager(compute_base.ComputeBase): self.manager = self._nova_client.servers self.server_groups = self._nova_client.server_groups self.flavor_manager = self._nova_client.flavors + self.availability_zone_manager = self._nova_client.availability_zones self.volume_driver = stevedore_driver.DriverManager( namespace='octavia.volume.drivers', name=CONF.controller_worker.volume_driver, @@ -398,3 +399,24 @@ class VirtualMachineManager(compute_base.ComputeBase): LOG.exception('Nova reports a failure getting flavor details for ' 'flavor ID %s: %s', flavor_id, e) raise + + def validate_availability_zone(self, availability_zone): + """Validates that an availability zone exists in nova. + + :param availability_zone: Name of the availability zone to lookup. + :raises: NotFound + :returns: None + """ + try: + compute_zones = [ + a.zoneName for a in self.availability_zone_manager.list( + detailed=False)] + if availability_zone not in compute_zones: + LOG.info('Availability zone %s was not found in nova. %s', + availability_zone, compute_zones) + raise exceptions.InvalidSubresource( + resource='Nova availability zone', id=availability_zone) + except Exception as e: + LOG.exception('Nova reports a failure getting listing ' + 'availability zones: %s', e) + raise diff --git a/octavia/db/base_models.py b/octavia/db/base_models.py index c52a017890..b5cd75e1dc 100644 --- a/octavia/db/base_models.py +++ b/octavia/db/base_models.py @@ -31,7 +31,8 @@ class OctaviaBase(models.ModelBase): # objects. if obj.__class__.__name__ in ['Member', 'Pool', 'LoadBalancer', 'Listener', 'Amphora', 'L7Policy', - 'L7Rule', 'Flavor', 'FlavorProfile']: + 'L7Rule', 'Flavor', 'FlavorProfile', + 'AvailabilityZoneProfile']: return obj.__class__.__name__ + obj.id if obj.__class__.__name__ in ['SessionPersistence', 'HealthMonitor']: return obj.__class__.__name__ + obj.pool_id @@ -48,6 +49,8 @@ class OctaviaBase(models.ModelBase): obj.listener_id + obj.tls_container_id) if obj.__class__.__name__ in ['Quotas']: return obj.__class__.__name__ + obj.project_id + if obj.__class__.__name__ in ['AvailabilityZone']: + return obj.__class__.__name__ + obj.name raise NotImplementedError def to_data_model(self, _graph_nodes=None): diff --git a/octavia/db/migration/alembic_migrations/versions/c761c8a71579_add_availability_zone_table.py b/octavia/db/migration/alembic_migrations/versions/c761c8a71579_add_availability_zone_table.py new file mode 100644 index 0000000000..417ae1b3ac --- /dev/null +++ b/octavia/db/migration/alembic_migrations/versions/c761c8a71579_add_availability_zone_table.py @@ -0,0 +1,71 @@ +# Copyright 2017 Walmart Stores Inc. +# +# 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. + +"""add availability_zone table + +Revision ID: c761c8a71579 +Revises: e37941b010db +Create Date: 2019-11-11 18:53:15.428386 + +""" + +from alembic import op +import sqlalchemy as sa + +from octavia.common import constants + +# revision identifiers, used by Alembic. +revision = 'c761c8a71579' +down_revision = 'e37941b010db' + + +def upgrade(): + azp_table = op.create_table( + u'availability_zone_profile', + sa.Column(u'id', sa.String(36), nullable=False), + sa.Column(u'name', sa.String(255), nullable=False), + sa.Column(u'provider_name', sa.String(255), nullable=False), + sa.Column(u'availability_zone_data', sa.String(4096), nullable=False), + sa.PrimaryKeyConstraint(u'id')) + + op.bulk_insert( + azp_table, + [ + {'id': constants.NIL_UUID, 'name': 'DELETED-PLACEHOLDER', + 'provider_name': 'DELETED', 'availability_zone_data': '{}'}, + ] + ) + + az_table = op.create_table( + u'availability_zone', + sa.Column(u'name', sa.String(255), nullable=False), + sa.Column(u'description', sa.String(255), nullable=True), + sa.Column(u'enabled', sa.Boolean(), nullable=False), + sa.Column(u'availability_zone_profile_id', sa.String(36), + nullable=False), + sa.ForeignKeyConstraint([u'availability_zone_profile_id'], + [u'availability_zone_profile.id'], + name=u'fk_az_az_profile_id'), + sa.PrimaryKeyConstraint(u'name'),) + + op.bulk_insert( + az_table, + [ + {'name': constants.NIL_UUID, + 'description': 'Placeholder for DELETED LBs with DELETED ' + 'availability zones', + 'enabled': False, + 'availability_zone_profile_id': constants.NIL_UUID} + ] + ) diff --git a/octavia/db/models.py b/octavia/db/models.py index 6b943dd6fc..4cd3b39dca 100644 --- a/octavia/db/models.py +++ b/octavia/db/models.py @@ -23,6 +23,8 @@ from sqlalchemy.orm import validates from sqlalchemy.sql import func from octavia.api.v2.types import amphora +from octavia.api.v2.types import availability_zone_profile +from octavia.api.v2.types import availability_zones from octavia.api.v2.types import flavor_profile from octavia.api.v2.types import flavors from octavia.api.v2.types import health_monitor @@ -782,6 +784,41 @@ class Flavor(base_models.BASE, nullable=False) +class AvailabilityZoneProfile(base_models.BASE, base_models.IdMixin, + base_models.NameMixin): + + __data_model__ = data_models.AvailabilityZoneProfile + + __tablename__ = "availability_zone_profile" + + __v2_wsme__ = availability_zone_profile.AvailabilityZoneProfileResponse + + provider_name = sa.Column(sa.String(255), nullable=False) + availability_zone_data = sa.Column(sa.String(4096), nullable=False) + + +class AvailabilityZone(base_models.BASE, + base_models.NameMixin): + + __data_model__ = data_models.AvailabilityZone + + __tablename__ = "availability_zone" + + __v2_wsme__ = availability_zones.AvailabilityZoneResponse + + __table_args__ = ( + sa.PrimaryKeyConstraint('name'), + ) + + description = sa.Column(sa.String(255), nullable=True) + enabled = sa.Column(sa.Boolean(), nullable=False) + availability_zone_profile_id = sa.Column( + sa.String(36), + sa.ForeignKey("availability_zone_profile.id", + name="fk_az_az_profile_id"), + nullable=False) + + class ClientAuthenticationMode(base_models.BASE): __tablename__ = "client_authentication_mode" diff --git a/octavia/db/repositories.py b/octavia/db/repositories.py index a03d910c89..e1a865df66 100644 --- a/octavia/db/repositories.py +++ b/octavia/db/repositories.py @@ -228,6 +228,8 @@ class Repositories(object): self.flavor = FlavorRepository() self.flavor_profile = FlavorProfileRepository() self.spares_pool = SparesPoolRepository() + self.availability_zone = AvailabilityZoneRepository() + self.availability_zone_profile = AvailabilityZoneProfileRepository() def create_load_balancer_and_vip(self, session, lb_dict, vip_dict): """Inserts load balancer and vip entities into the database. @@ -1828,7 +1830,10 @@ class _GetALLExceptDELETEDIdMixin(object): if query_options: query = query.options(query_options) - query = query.filter(self.model_class.id != consts.NIL_UUID) + if hasattr(self.model_class, 'id'): + query = query.filter(self.model_class.id != consts.NIL_UUID) + else: + query = query.filter(self.model_class.name != consts.NIL_UUID) if pagination_helper: model_list, links = pagination_helper.apply( @@ -1898,3 +1903,69 @@ class SparesPoolRepository(BaseRepository): """ row = lock_session.query(models.SparesPool).with_for_update().one() return row + + +class AvailabilityZoneRepository(_GetALLExceptDELETEDIdMixin, BaseRepository): + model_class = models.AvailabilityZone + + def get_availability_zone_metadata_dict(self, session, + availability_zone_name): + with session.begin(subtransactions=True): + availability_zone_metadata_json = ( + session.query( + models.AvailabilityZoneProfile.availability_zone_data) + .filter(models.AvailabilityZone.name == availability_zone_name) + .filter(models.AvailabilityZone.availability_zone_profile_id == + models.AvailabilityZoneProfile.id) + .one()[0]) + result_dict = ( + {} if availability_zone_metadata_json is None + else jsonutils.loads(availability_zone_metadata_json)) + return result_dict + + def get_availability_zone_provider(self, session, availability_zone_name): + with session.begin(subtransactions=True): + return (session.query(models.AvailabilityZoneProfile.provider_name) + .filter( + models.AvailabilityZone.name == availability_zone_name) + .filter( + models.AvailabilityZone.availability_zone_profile_id == + models.AvailabilityZoneProfile.id).one()[0]) + + def update(self, session, name, **model_kwargs): + """Updates an entity in the database. + + :param session: A Sql Alchemy database session. + :param model_kwargs: Entity attributes that should be updates. + :returns: octavia.common.data_model + """ + with session.begin(subtransactions=True): + session.query(self.model_class).filter_by( + name=name).update(model_kwargs) + + def delete(self, serial_session, **filters): + """Special delete method for availability_zone. + + Sets DELETED LBs availability_zone_id to NIL_UUID, then removes the + availability_zone. + + :param serial_session: A Sql Alchemy database transaction session. + :param filters: Filters to decide which entity should be deleted. + :returns: None + :raises: odb_exceptions.DBReferenceError + :raises: sqlalchemy.orm.exc.NoResultFound + """ + # TODO(sorrison): Uncomment this + # (serial_session.query(models.LoadBalancer). + # filter(models.LoadBalancer.availability_zone_id == filters['id']). + # filter(models.LoadBalancer.provisioning_status == consts.DELETED). + # update({models.LoadBalancer.availability_zone_id: consts.NIL_UUID}, + # synchronize_session=False)) + availability_zone = ( + serial_session.query(self.model_class).filter_by(**filters).one()) + serial_session.delete(availability_zone) + + +class AvailabilityZoneProfileRepository(_GetALLExceptDELETEDIdMixin, + BaseRepository): + model_class = models.AvailabilityZoneProfile diff --git a/octavia/policies/__init__.py b/octavia/policies/__init__.py index 242770fc6b..8e007511eb 100644 --- a/octavia/policies/__init__.py +++ b/octavia/policies/__init__.py @@ -14,6 +14,8 @@ import itertools from octavia.policies import amphora +from octavia.policies import availability_zone +from octavia.policies import availability_zone_profile from octavia.policies import base from octavia.policies import flavor from octavia.policies import flavor_profile @@ -25,6 +27,7 @@ from octavia.policies import loadbalancer from octavia.policies import member from octavia.policies import pool from octavia.policies import provider +from octavia.policies import provider_availability_zone from octavia.policies import provider_flavor from octavia.policies import quota @@ -34,6 +37,8 @@ def list_rules(): base.list_rules(), flavor.list_rules(), flavor_profile.list_rules(), + availability_zone.list_rules(), + availability_zone_profile.list_rules(), healthmonitor.list_rules(), l7policy.list_rules(), l7rule.list_rules(), @@ -45,4 +50,5 @@ def list_rules(): quota.list_rules(), amphora.list_rules(), provider_flavor.list_rules(), + provider_availability_zone.list_rules(), ) diff --git a/octavia/policies/availability_zone.py b/octavia/policies/availability_zone.py new file mode 100644 index 0000000000..37a3e2ab71 --- /dev/null +++ b/octavia/policies/availability_zone.py @@ -0,0 +1,62 @@ +# Copyright 2019 Verizon Media +# 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 oslo_policy import policy + +from octavia.common import constants + + +rules = [ + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AVAILABILITY_ZONE, + action=constants.RBAC_GET_ALL), + constants.RULE_API_READ, + "List Availability Zones", + [{'method': 'GET', 'path': '/v2.0/lbaas/availabilityzones'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AVAILABILITY_ZONE, + action=constants.RBAC_POST), + constants.RULE_API_ADMIN, + "Create an Availability Zone", + [{'method': 'POST', 'path': '/v2.0/lbaas/availabilityzones'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AVAILABILITY_ZONE, + action=constants.RBAC_PUT), + constants.RULE_API_ADMIN, + "Update an Availability Zone", + [{'method': 'PUT', + 'path': '/v2.0/lbaas/availabilityzones/{availability_zone_id}'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AVAILABILITY_ZONE, + action=constants.RBAC_GET_ONE), + constants.RULE_API_READ, + "Show Availability Zone details", + [{'method': 'GET', + 'path': '/v2.0/lbaas/availabilityzones/{availability_zone_id}'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AVAILABILITY_ZONE, + action=constants.RBAC_DELETE), + constants.RULE_API_ADMIN, + "Remove an Availability Zone", + [{'method': 'DELETE', + 'path': '/v2.0/lbaas/availabilityzones/{availability_zone_id}'}] + ), +] + + +def list_rules(): + return rules diff --git a/octavia/policies/availability_zone_profile.py b/octavia/policies/availability_zone_profile.py new file mode 100644 index 0000000000..8908b3df24 --- /dev/null +++ b/octavia/policies/availability_zone_profile.py @@ -0,0 +1,70 @@ +# Copyright 2019 Verizon Media +# 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 oslo_policy import policy + +from octavia.common import constants + + +rules = [ + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format( + rbac_obj=constants.RBAC_AVAILABILITY_ZONE_PROFILE, + action=constants.RBAC_GET_ALL), + constants.RULE_API_ADMIN, + "List Availability Zones", + [{'method': 'GET', 'path': '/v2.0/lbaas/availabilityzoneprofiles'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format( + rbac_obj=constants.RBAC_AVAILABILITY_ZONE_PROFILE, + action=constants.RBAC_POST), + constants.RULE_API_ADMIN, + "Create an Availability Zone", + [{'method': 'POST', 'path': '/v2.0/lbaas/availabilityzoneprofiles'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format( + rbac_obj=constants.RBAC_AVAILABILITY_ZONE_PROFILE, + action=constants.RBAC_PUT), + constants.RULE_API_ADMIN, + "Update an Availability Zone", + [{'method': 'PUT', + 'path': '/v2.0/lbaas/availabilityzoneprofiles/' + '{availability_zone_profile_id}'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format( + rbac_obj=constants.RBAC_AVAILABILITY_ZONE_PROFILE, + action=constants.RBAC_GET_ONE), + constants.RULE_API_ADMIN, + "Show Availability Zone details", + [{'method': 'GET', + 'path': '/v2.0/lbaas/availabilityzoneprofiles/' + '{availability_zone_profile_id}'}] + ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format( + rbac_obj=constants.RBAC_AVAILABILITY_ZONE_PROFILE, + action=constants.RBAC_DELETE), + constants.RULE_API_ADMIN, + "Remove an Availability Zone", + [{'method': 'DELETE', + 'path': '/v2.0/lbaas/availabilityzoneprofiles/' + '{availability_zone_profile_id}'}] + ), +] + + +def list_rules(): + return rules diff --git a/octavia/policies/flavor.py b/octavia/policies/flavor.py index f0448df43b..b9e6b2938e 100644 --- a/octavia/policies/flavor.py +++ b/octavia/policies/flavor.py @@ -50,7 +50,7 @@ rules = [ '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_FLAVOR, action=constants.RBAC_DELETE), constants.RULE_API_ADMIN, - "Remove a flavor", + "Remove a Flavor", [{'method': 'DELETE', 'path': '/v2.0/lbaas/flavors/{flavor_id}'}] ), diff --git a/octavia/policies/flavor_profile.py b/octavia/policies/flavor_profile.py index bdf8ca6fd5..e867e2394a 100644 --- a/octavia/policies/flavor_profile.py +++ b/octavia/policies/flavor_profile.py @@ -21,21 +21,21 @@ rules = [ '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_FLAVOR_PROFILE, action=constants.RBAC_GET_ALL), constants.RULE_API_ADMIN, - "List Flavors", + "List Flavor Profiles", [{'method': 'GET', 'path': '/v2.0/lbaas/flavorprofiles'}] ), policy.DocumentedRuleDefault( '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_FLAVOR_PROFILE, action=constants.RBAC_POST), constants.RULE_API_ADMIN, - "Create a Flavor", + "Create a Flavor Profile", [{'method': 'POST', 'path': '/v2.0/lbaas/flavorprofiles'}] ), policy.DocumentedRuleDefault( '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_FLAVOR_PROFILE, action=constants.RBAC_PUT), constants.RULE_API_ADMIN, - "Update a Flavor", + "Update a Flavor Profile", [{'method': 'PUT', 'path': '/v2.0/lbaas/flavorprofiles/{flavor_profile_id}'}] ), @@ -43,7 +43,7 @@ rules = [ '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_FLAVOR_PROFILE, action=constants.RBAC_GET_ONE), constants.RULE_API_ADMIN, - "Show Flavor details", + "Show Flavor Profile details", [{'method': 'GET', 'path': '/v2.0/lbaas/flavorprofiles/{flavor_profile_id}'}] ), @@ -51,7 +51,7 @@ rules = [ '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_FLAVOR_PROFILE, action=constants.RBAC_DELETE), constants.RULE_API_ADMIN, - "Remove a flavor", + "Remove a Flavor Profile", [{'method': 'DELETE', 'path': '/v2.0/lbaas/flavorprofiles/{flavor_profile_id}'}] ), diff --git a/octavia/policies/provider_availability_zone.py b/octavia/policies/provider_availability_zone.py new file mode 100644 index 0000000000..9ff054f99a --- /dev/null +++ b/octavia/policies/provider_availability_zone.py @@ -0,0 +1,33 @@ +# Copyright 2018 Rackspace, US Inc. +# 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 oslo_policy import policy + +from octavia.common import constants + +rules = [ + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format( + rbac_obj=constants.RBAC_PROVIDER_AVAILABILITY_ZONE, + action=constants.RBAC_GET_ALL), + constants.RULE_API_ADMIN, + "List the provider availability zone capabilities.", + [{'method': 'GET', + 'path': '/v2/lbaas/providers/{provider}/' + 'availability_zone_capabilities'}] + ), +] + + +def list_rules(): + return rules diff --git a/octavia/policies/provider_flavor.py b/octavia/policies/provider_flavor.py index 858eacd9b7..24ccbdd6dd 100644 --- a/octavia/policies/provider_flavor.py +++ b/octavia/policies/provider_flavor.py @@ -22,7 +22,7 @@ rules = [ constants.RULE_API_ADMIN, "List the provider flavor capabilities.", [{'method': 'GET', - 'path': '/v2/lbaas/providers/{provider}/capabilities'}] + 'path': '/v2/lbaas/providers/{provider}/flavor_capabilities'}] ), ] diff --git a/octavia/tests/functional/api/test_root_controller.py b/octavia/tests/functional/api/test_root_controller.py index 09d44cc6bf..50122ff2d3 100644 --- a/octavia/tests/functional/api/test_root_controller.py +++ b/octavia/tests/functional/api/test_root_controller.py @@ -43,7 +43,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase): def test_api_versions(self): versions = self._get_versions_with_config() version_ids = tuple(v.get('id') for v in versions) - self.assertEqual(14, len(version_ids)) + self.assertEqual(15, len(version_ids)) self.assertIn('v2.0', version_ids) self.assertIn('v2.1', version_ids) self.assertIn('v2.2', version_ids) @@ -58,6 +58,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase): self.assertIn('v2.11', version_ids) self.assertIn('v2.12', version_ids) self.assertIn('v2.13', version_ids) + self.assertIn('v2.14', version_ids) # Each version should have a 'self' 'href' to the API version URL # [{u'rel': u'self', u'href': u'http://localhost/v2'}] diff --git a/octavia/tests/functional/api/v2/base.py b/octavia/tests/functional/api/v2/base.py index 199a69706a..2e89104bc5 100644 --- a/octavia/tests/functional/api/v2/base.py +++ b/octavia/tests/functional/api/v2/base.py @@ -40,6 +40,14 @@ class BaseAPITest(base_db_test.OctaviaDBTestBase): FPS_PATH = '/flavorprofiles' FP_PATH = FPS_PATH + '/{fp_id}' + # /lbaas/availabilityzones + AZS_PATH = '/availabilityzones' + AZ_PATH = AZS_PATH + '/{az_name}' + + # /lbaas/availabilityzoneprofiles + AZPS_PATH = '/availabilityzoneprofiles' + AZP_PATH = AZPS_PATH + '/{azp_id}' + # /lbaas/loadbalancers LBS_PATH = '/lbaas/loadbalancers' LB_PATH = LBS_PATH + '/{lb_id}' @@ -80,8 +88,10 @@ class BaseAPITest(base_db_test.OctaviaDBTestBase): AMPHORA_CONFIG_PATH = AMPHORA_PATH + '/config' PROVIDERS_PATH = '/lbaas/providers' - FLAVOR_CAPABILITIES_PATH = (PROVIDERS_PATH + - '/{provider}/flavor_capabilities') + FLAVOR_CAPABILITIES_PATH = ( + PROVIDERS_PATH + '/{provider}/flavor_capabilities') + AVAILABILITY_ZONE_CAPABILITIES_PATH = ( + PROVIDERS_PATH + '/{provider}/availability_zone_capabilities') NOT_AUTHORIZED_BODY = { 'debuginfo': None, 'faultcode': 'Client', @@ -205,13 +215,31 @@ class BaseAPITest(base_db_test.OctaviaDBTestBase): response = self.post(self.FLAVORS_PATH, body) return response.json.get('flavor') - def create_flavor_profile(self, name, privider_name, flavor_data): - req_dict = {'name': name, 'provider_name': privider_name, + def create_flavor_profile(self, name, provider_name, flavor_data): + req_dict = {'name': name, 'provider_name': provider_name, constants.FLAVOR_DATA: flavor_data} body = {'flavorprofile': req_dict} response = self.post(self.FPS_PATH, body) return response.json.get('flavorprofile') + def create_availability_zone(self, name, description, + availability_zone_profile_id, enabled): + req_dict = { + 'name': name, 'description': description, + 'availability_zone_profile_id': availability_zone_profile_id, + 'enabled': enabled} + body = {'availability_zone': req_dict} + response = self.post(self.AZS_PATH, body) + return response.json.get('availability_zone') + + def create_availability_zone_profile(self, name, provider_name, + availability_zone_data): + req_dict = {'name': name, 'provider_name': provider_name, + constants.AVAILABILITY_ZONE_DATA: availability_zone_data} + body = {'availability_zone_profile': req_dict} + response = self.post(self.AZPS_PATH, body) + return response.json.get('availability_zone_profile') + def create_load_balancer(self, vip_subnet_id, **optionals): req_dict = {'vip_subnet_id': vip_subnet_id, diff --git a/octavia/tests/functional/api/v2/test_availability_zone_profiles.py b/octavia/tests/functional/api/v2/test_availability_zone_profiles.py new file mode 100644 index 0000000000..12ccb84518 --- /dev/null +++ b/octavia/tests/functional/api/v2/test_availability_zone_profiles.py @@ -0,0 +1,589 @@ +# Copyright 2019 Verizon Media +# +# 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 mock + +from oslo_config import cfg +from oslo_config import fixture as oslo_fixture +from oslo_db import exception as odb_exceptions +from oslo_utils import uuidutils + +from octavia.common import constants +import octavia.common.context +from octavia.tests.functional.api.v2 import base + + +class TestAvailabilityZoneProfiles(base.BaseAPITest): + root_tag = 'availability_zone_profile' + root_tag_list = 'availability_zone_profiles' + root_tag_links = 'availability_zone_profile_links' + + def _assert_request_matches_response(self, req, resp, **optionals): + self.assertTrue(uuidutils.is_uuid_like(resp.get('id'))) + self.assertEqual(req.get('name'), resp.get('name')) + self.assertEqual(req.get(constants.PROVIDER_NAME), + resp.get(constants.PROVIDER_NAME)) + self.assertEqual(req.get(constants.AVAILABILITY_ZONE_DATA), + resp.get(constants.AVAILABILITY_ZONE_DATA)) + + def test_empty_list(self): + response = self.get(self.AZPS_PATH) + api_list = response.json.get(self.root_tag_list) + self.assertEqual([], api_list) + + def test_create(self): + az_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(az_json) + response = self.post(self.AZPS_PATH, body) + api_azp = response.json.get(self.root_tag) + self._assert_request_matches_response(az_json, api_azp) + + def test_create_with_missing_name(self): + az_json = {constants.PROVIDER_NAME: 'pr1', + constants.AVAILABILITY_ZONE_DATA: '{"x": "y"}'} + body = self._build_body(az_json) + response = self.post(self.AZPS_PATH, body, status=400) + err_msg = ("Invalid input for field/attribute name. Value: " + "'None'. Mandatory field missing.") + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_create_with_missing_provider(self): + az_json = {'name': 'xyz', + constants.AVAILABILITY_ZONE_DATA: '{"x": "y"}'} + body = self._build_body(az_json) + response = self.post(self.AZPS_PATH, body, status=400) + err_msg = ("Invalid input for field/attribute provider_name. " + "Value: 'None'. Mandatory field missing.") + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_create_with_missing_availability_zone_data(self): + az_json = {'name': 'xyz', constants.PROVIDER_NAME: 'pr1'} + body = self._build_body(az_json) + response = self.post(self.AZPS_PATH, body, status=400) + err_msg = ("Invalid input for field/attribute availability_zone_data. " + "Value: 'None'. Mandatory field missing.") + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_create_with_empty_availability_zone_data(self): + az_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', + constants.AVAILABILITY_ZONE_DATA: '{}'} + body = self._build_body(az_json) + response = self.post(self.AZPS_PATH, body) + api_azp = response.json.get(self.root_tag) + self._assert_request_matches_response(az_json, api_azp) + + def test_create_with_long_name(self): + az_json = {'name': 'n' * 256, constants.PROVIDER_NAME: 'test1', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(az_json) + self.post(self.AZPS_PATH, body, status=400) + + def test_create_with_long_provider(self): + az_json = {'name': 'name1', constants.PROVIDER_NAME: 'n' * 256, + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(az_json) + self.post(self.AZPS_PATH, body, status=400) + + def test_create_with_long_availability_zone_data(self): + az_json = {'name': 'name1', constants.PROVIDER_NAME: 'amp', + constants.AVAILABILITY_ZONE_DATA: 'n' * 4097} + body = self._build_body(az_json) + self.post(self.AZPS_PATH, body, status=400) + + def test_create_authorized(self): + az_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(az_json) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + response = self.post(self.AZPS_PATH, body) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + api_azp = response.json.get(self.root_tag) + self._assert_request_matches_response(az_json, api_azp) + + def test_create_not_authorized(self): + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + az_json = {'name': 'name', + constants.PROVIDER_NAME: 'xyz', + constants.AVAILABILITY_ZONE_DATA: '{"x": "y"}'} + body = self._build_body(az_json) + response = self.post(self.AZPS_PATH, body, status=403) + api_azp = response.json + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.NOT_AUTHORIZED_BODY, api_azp) + + def test_create_db_failure(self): + az_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(az_json) + with mock.patch( + "octavia.db.repositories.AvailabilityZoneProfileRepository." + "create") as mock_create: + mock_create.side_effect = Exception + self.post(self.AZPS_PATH, body, status=500) + + mock_create.side_effect = odb_exceptions.DBDuplicateEntry + self.post(self.AZPS_PATH, body, status=409) + + def test_create_with_invalid_json(self): + az_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', + constants.AVAILABILITY_ZONE_DATA: '{hello: "world"}'} + body = self._build_body(az_json) + self.post(self.AZPS_PATH, body, status=400) + + def test_get(self): + azp = self.create_availability_zone_profile( + 'name', 'noop_driver', '{"x": "y"}') + self.assertTrue(uuidutils.is_uuid_like(azp.get('id'))) + response = self.get( + self.AZP_PATH.format( + azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('name', response.get('name')) + self.assertEqual(azp.get('id'), response.get('id')) + + def test_get_one_deleted_id(self): + response = self.get(self.AZP_PATH.format(azp_id=constants.NIL_UUID), + status=404) + self.assertEqual('Availability Zone Profile {} not found.'.format( + constants.NIL_UUID), response.json.get('faultstring')) + + def test_get_one_fields_filter(self): + azp = self.create_availability_zone_profile( + 'name', 'noop_driver', '{"x": "y"}') + self.assertTrue(uuidutils.is_uuid_like(azp.get('id'))) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id')), params={ + 'fields': ['id', constants.PROVIDER_NAME]} + ).json.get(self.root_tag) + self.assertEqual(azp.get('id'), response.get('id')) + self.assertIn(u'id', response) + self.assertIn(constants.PROVIDER_NAME, response) + self.assertNotIn(u'name', response) + self.assertNotIn(constants.AVAILABILITY_ZONE_DATA, response) + + def test_get_authorized(self): + azp = self.create_availability_zone_profile( + 'name', 'noop_driver', '{"x": "y"}') + self.assertTrue(uuidutils.is_uuid_like(azp.get('id'))) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + response = self.get( + self.AZP_PATH.format( + azp_id=azp.get('id'))).json.get(self.root_tag) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual('name', response.get('name')) + self.assertEqual(azp.get('id'), response.get('id')) + + def test_get_not_authorized(self): + azp = self.create_availability_zone_profile( + 'name', 'noop_driver', '{"x": "y"}') + self.assertTrue(uuidutils.is_uuid_like(azp.get('id'))) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + self.get(self.AZP_PATH.format(azp_id=azp.get('id')), status=403) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + + def test_get_all(self): + fp1 = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + ref_fp_1 = {u'availability_zone_data': u'{"compute_zone": "my_az_1"}', + u'id': fp1.get('id'), u'name': u'test1', + constants.PROVIDER_NAME: u'noop_driver'} + self.assertTrue(uuidutils.is_uuid_like(fp1.get('id'))) + fp2 = self.create_availability_zone_profile( + 'test2', 'noop_driver-alt', '{"compute_zone": "my_az_1"}') + ref_fp_2 = {u'availability_zone_data': u'{"compute_zone": "my_az_1"}', + u'id': fp2.get('id'), u'name': u'test2', + constants.PROVIDER_NAME: u'noop_driver-alt'} + self.assertTrue(uuidutils.is_uuid_like(fp2.get('id'))) + + response = self.get(self.AZPS_PATH) + api_list = response.json.get(self.root_tag_list) + self.assertEqual(2, len(api_list)) + self.assertIn(ref_fp_1, api_list) + self.assertIn(ref_fp_2, api_list) + + def test_get_all_fields_filter(self): + fp1 = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(fp1.get('id'))) + fp2 = self.create_availability_zone_profile( + 'test2', 'noop_driver-alt', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(fp2.get('id'))) + + response = self.get(self.AZPS_PATH, params={ + 'fields': ['id', 'name']}) + api_list = response.json.get(self.root_tag_list) + self.assertEqual(2, len(api_list)) + for profile in api_list: + self.assertIn(u'id', profile) + self.assertIn(u'name', profile) + self.assertNotIn(constants.PROVIDER_NAME, profile) + self.assertNotIn(constants.AVAILABILITY_ZONE_DATA, profile) + + def test_get_all_authorized(self): + fp1 = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(fp1.get('id'))) + fp2 = self.create_availability_zone_profile( + 'test2', 'noop_driver-alt', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(fp2.get('id'))) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + response = self.get(self.AZPS_PATH) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + api_list = response.json.get(self.root_tag_list) + self.assertEqual(2, len(api_list)) + + def test_get_all_not_authorized(self): + fp1 = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(fp1.get('id'))) + fp2 = self.create_availability_zone_profile( + 'test2', 'noop_driver-alt', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(fp2.get('id'))) + + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + self.get(self.AZPS_PATH, status=403) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + + def test_update(self): + azp = self.create_availability_zone_profile( + 'test_profile', 'noop_driver', '{"x": "y"}') + update_data = {'name': 'the_profile', + constants.PROVIDER_NAME: 'noop_driver-alt', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(update_data) + self.put(self.AZP_PATH.format(azp_id=azp.get('id')), body) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('the_profile', response.get('name')) + self.assertEqual('noop_driver-alt', + response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"hello": "world"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + def test_update_deleted_id(self): + update_data = {'name': 'fake_profile'} + body = self._build_body(update_data) + response = self.put(self.AZP_PATH.format(azp_id=constants.NIL_UUID), + body, status=404) + self.assertEqual('Availability Zone Profile {} not found.'.format( + constants.NIL_UUID), response.json.get('faultstring')) + + def test_update_nothing(self): + azp = self.create_availability_zone_profile( + 'test_profile', 'noop_driver', '{"x": "y"}') + body = self._build_body({}) + self.put(self.AZP_PATH.format(azp_id=azp.get('id')), body) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('test_profile', response.get('name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"x": "y"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + def test_update_name_none(self): + self._test_update_param_none(constants.NAME) + + def test_update_provider_name_none(self): + self._test_update_param_none(constants.PROVIDER_NAME) + + def test_update_availability_zone_data_none(self): + self._test_update_param_none(constants.AVAILABILITY_ZONE_DATA) + + def _test_update_param_none(self, param_name): + azp = self.create_availability_zone_profile( + 'test_profile', 'noop_driver', '{"x": "y"}') + expect_error_msg = ("None is not a valid option for %s" % + param_name) + body = self._build_body({param_name: None}) + response = self.put(self.AZP_PATH.format(azp_id=azp.get('id')), body, + status=400) + self.assertEqual(expect_error_msg, response.json['faultstring']) + + def test_update_no_availability_zone_data(self): + azp = self.create_availability_zone_profile( + 'test_profile', 'noop_driver', '{"x": "y"}') + update_data = {'name': 'the_profile', + constants.PROVIDER_NAME: 'noop_driver-alt'} + body = self._build_body(update_data) + response = self.put(self.AZP_PATH.format(azp_id=azp.get('id')), body) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('the_profile', response.get('name')) + self.assertEqual('noop_driver-alt', + response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"x": "y"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + def test_update_authorized(self): + azp = self.create_availability_zone_profile( + 'test_profile', 'noop_driver', '{"x": "y"}') + update_data = {'name': 'the_profile', + constants.PROVIDER_NAME: 'noop_driver-alt', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(update_data) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + response = self.put(self.AZP_PATH.format(azp_id=azp.get('id')), + body) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('the_profile', response.get('name')) + self.assertEqual('noop_driver-alt', + response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"hello": "world"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + def test_update_not_authorized(self): + azp = self.create_availability_zone_profile( + 'test_profile', 'noop_driver', '{"x": "y"}') + update_data = {'name': 'the_profile', constants.PROVIDER_NAME: 'amp', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(update_data) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + response = self.put(self.AZP_PATH.format(azp_id=azp.get('id')), + body, status=403) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('test_profile', response.get('name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"x": "y"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + def test_update_in_use(self): + azp = self.create_availability_zone_profile( + 'test_profile', 'noop_driver', '{"x": "y"}') + self.create_availability_zone( + 'name1', 'description', azp.get('id'), True) + + # Test updating provider while in use is not allowed + update_data = {'name': 'the_profile', + constants.PROVIDER_NAME: 'noop_driver-alt'} + body = self._build_body(update_data) + response = self.put(self.AZP_PATH.format(azp_id=azp.get('id')), body, + status=409) + err_msg = ("Availability Zone Profile {} is in use and cannot be " + "modified.".format(azp.get('id'))) + self.assertEqual(err_msg, response.json.get('faultstring')) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('test_profile', response.get('name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"x": "y"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + # Test updating availability zone data while in use is not allowed + update_data = {'name': 'the_profile', + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + body = self._build_body(update_data) + response = self.put(self.AZP_PATH.format(azp_id=azp.get('id')), body, + status=409) + err_msg = ("Availability Zone Profile {} is in use and cannot be " + "modified.".format(azp.get('id'))) + self.assertEqual(err_msg, response.json.get('faultstring')) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('test_profile', response.get('name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"x": "y"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + # Test that you can still update the name when in use + update_data = {'name': 'the_profile'} + body = self._build_body(update_data) + response = self.put(self.AZP_PATH.format(azp_id=azp.get('id')), body) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('the_profile', response.get('name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) + self.assertEqual('{"x": "y"}', + response.get(constants.AVAILABILITY_ZONE_DATA)) + + def test_delete(self): + azp = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(azp.get('id'))) + self.delete(self.AZP_PATH.format(azp_id=azp.get('id'))) + response = self.get(self.AZP_PATH.format( + azp_id=azp.get('id')), status=404) + err_msg = "Availability Zone Profile %s not found." % azp.get('id') + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_delete_deleted_id(self): + response = self.delete(self.AZP_PATH.format(azp_id=constants.NIL_UUID), + status=404) + self.assertEqual('Availability Zone Profile {} not found.'.format( + constants.NIL_UUID), response.json.get('faultstring')) + + def test_delete_nonexistent_id(self): + response = self.delete(self.AZP_PATH.format(azp_id='bogus_id'), + status=404) + self.assertEqual('Availability Zone Profile bogus_id not found.', + response.json.get('faultstring')) + + def test_delete_authorized(self): + azp = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(azp.get('id'))) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + self.delete(self.AZP_PATH.format(azp_id=azp.get('id'))) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + response = self.get(self.AZP_PATH.format( + azp_id=azp.get('id')), status=404) + err_msg = "Availability Zone Profile %s not found." % azp.get('id') + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_delete_not_authorized(self): + azp = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + self.assertTrue(uuidutils.is_uuid_like(azp.get('id'))) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + + response = self.delete(self.AZP_PATH.format( + azp_id=azp.get('id')), status=403) + api_azp = response.json + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.NOT_AUTHORIZED_BODY, api_azp) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('test1', response.get('name')) + + def test_delete_in_use(self): + azp = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + self.create_availability_zone( + 'name1', 'description', azp.get('id'), True) + response = self.delete(self.AZP_PATH.format(azp_id=azp.get('id')), + status=409) + err_msg = ("Availability Zone Profile {} is in use and cannot be " + "modified.".format(azp.get('id'))) + self.assertEqual(err_msg, response.json.get('faultstring')) + response = self.get( + self.AZP_PATH.format(azp_id=azp.get('id'))).json.get(self.root_tag) + self.assertEqual('test1', response.get('name')) diff --git a/octavia/tests/functional/api/v2/test_availability_zones.py b/octavia/tests/functional/api/v2/test_availability_zones.py new file mode 100644 index 0000000000..5172369291 --- /dev/null +++ b/octavia/tests/functional/api/v2/test_availability_zones.py @@ -0,0 +1,581 @@ +# Copyright 2019 Verizon Media +# +# 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 mock + +from oslo_utils import uuidutils + +from oslo_config import cfg +from oslo_config import fixture as oslo_fixture + +from octavia.common import constants +import octavia.common.context +from octavia.common import exceptions +from octavia.tests.functional.api.v2 import base + + +class TestAvailabilityZones(base.BaseAPITest): + root_tag = 'availability_zone' + root_tag_list = 'availability_zones' + root_tag_links = 'availability_zones_links' + + def setUp(self): + super(TestAvailabilityZones, self).setUp() + self.azp = self.create_availability_zone_profile( + 'test1', 'noop_driver', '{"compute_zone": "my_az_1"}') + + def _assert_request_matches_response(self, req, resp, **optionals): + self.assertTrue('id' not in resp) # AZs do not expose an ID + req_description = req.get('description') + self.assertEqual(req.get('name'), resp.get('name')) + if not req_description: + self.assertEqual('', resp.get('description')) + else: + self.assertEqual(req.get('description'), resp.get('description')) + self.assertEqual(req.get('availability_zone_profile_id'), + resp.get('availability_zone_profile_id')) + self.assertEqual(req.get('enabled', True), + resp.get('enabled')) + + def test_empty_list(self): + response = self.get(self.AZS_PATH) + api_list = response.json.get(self.root_tag_list) + self.assertEqual([], api_list) + + def test_create(self): + az_json = {'name': 'test1', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body) + api_az = response.json.get(self.root_tag) + self._assert_request_matches_response(az_json, api_az) + + def test_create_with_missing_name(self): + az_json = {'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body, status=400) + err_msg = ("Invalid input for field/attribute name. Value: " + "'None'. Mandatory field missing.") + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_create_with_long_name(self): + az_json = {'name': 'n' * 256, + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + self.post(self.AZS_PATH, body, status=400) + + def test_create_with_long_description(self): + az_json = {'name': 'test-az', + 'description': 'n' * 256, + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + self.post(self.AZS_PATH, body, status=400) + + def test_create_with_missing_availability_zone_profile(self): + az_json = {'name': 'xyz'} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body, status=400) + err_msg = ( + "Invalid input for field/attribute availability_zone_profile_id. " + "Value: 'None'. Mandatory field missing.") + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_create_with_bad_availability_zone_profile(self): + az_json = {'name': 'xyz', 'availability_zone_profile_id': 'bogus'} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body, status=400) + err_msg = ( + "Invalid input for field/attribute availability_zone_profile_id. " + "Value: 'bogus'. Value should be UUID format") + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_create_duplicate_names(self): + self.create_availability_zone( + 'name', 'description', self.azp.get('id'), True) + az_json = {'name': 'name', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body, status=409) + err_msg = "A availability zone of name already exists." + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_create_authorized(self): + az_json = {'name': 'test1', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + response = self.post(self.AZS_PATH, body) + api_az = response.json.get(self.root_tag) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self._assert_request_matches_response(az_json, api_az) + + def test_create_not_authorized(self): + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + az_json = {'name': 'name', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body, status=403) + api_az = response.json + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.NOT_AUTHORIZED_BODY, api_az) + + def test_create_db_failure(self): + az_json = {'name': 'test1', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + with mock.patch("octavia.db.repositories.AvailabilityZoneRepository." + "create") as mock_create: + mock_create.side_effect = Exception + self.post(self.AZS_PATH, body, status=500) + + def test_get(self): + az = self.create_availability_zone( + 'name', 'description', self.azp.get('id'), True) + response = self.get( + self.AZ_PATH.format( + az_name=az.get('name'))).json.get(self.root_tag) + self.assertEqual('name', response.get('name')) + self.assertEqual('description', response.get('description')) + self.assertEqual(az.get('name'), response.get('name')) + self.assertEqual(self.azp.get('id'), + response.get('availability_zone_profile_id')) + self.assertTrue(response.get('enabled')) + + def test_get_one_fields_filter(self): + az = self.create_availability_zone( + 'name', 'description', self.azp.get('id'), True) + response = self.get( + self.AZ_PATH.format(az_name=az.get('name')), params={ + 'fields': ['name', 'availability_zone_profile_id']} + ).json.get(self.root_tag) + self.assertEqual(az.get('name'), response.get('name')) + self.assertEqual(self.azp.get('id'), + response.get('availability_zone_profile_id')) + self.assertIn(u'availability_zone_profile_id', response) + self.assertNotIn(u'description', response) + self.assertNotIn(u'enabled', response) + + def test_get_one_deleted_name(self): + response = self.get( + self.AZ_PATH.format(az_name=constants.NIL_UUID), status=404) + self.assertEqual( + 'Availability Zone {} not found.'.format(constants.NIL_UUID), + response.json.get('faultstring')) + + def test_get_authorized(self): + az = self.create_availability_zone( + 'name', 'description', self.azp.get('id'), True) + + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': False, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + response = self.get( + self.AZ_PATH.format( + az_name=az.get('name'))).json.get(self.root_tag) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual('name', response.get('name')) + self.assertEqual('description', response.get('description')) + self.assertEqual(self.azp.get('id'), + response.get('availability_zone_profile_id')) + self.assertTrue(response.get('enabled')) + + def test_get_not_authorized(self): + az = self.create_availability_zone( + 'name', 'description', self.azp.get('id'), True) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + response = self.get(self.AZ_PATH.format( + az_name=az.get('name')), status=403).json + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.NOT_AUTHORIZED_BODY, response) + + def test_get_all(self): + self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + ref_az_1 = { + u'description': u'description', u'enabled': True, + u'availability_zone_profile_id': self.azp.get('id'), + u'name': u'name1'} + self.create_availability_zone( + 'name2', 'description', self.azp.get('id'), True) + ref_az_2 = { + u'description': u'description', u'enabled': True, + u'availability_zone_profile_id': self.azp.get('id'), + u'name': u'name2'} + response = self.get(self.AZS_PATH) + api_list = response.json.get(self.root_tag_list) + self.assertEqual(2, len(api_list)) + self.assertIn(ref_az_1, api_list) + self.assertIn(ref_az_2, api_list) + + def test_get_all_fields_filter(self): + self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + self.create_availability_zone( + 'name2', 'description', self.azp.get('id'), True) + response = self.get(self.AZS_PATH, params={ + 'fields': ['id', 'name']}) + api_list = response.json.get(self.root_tag_list) + self.assertEqual(2, len(api_list)) + for az in api_list: + self.assertIn(u'name', az) + self.assertNotIn(u'availability_zone_profile_id', az) + self.assertNotIn(u'description', az) + self.assertNotIn(u'enabled', az) + + def test_get_all_authorized(self): + self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + self.create_availability_zone( + 'name2', 'description', self.azp.get('id'), True) + response = self.get(self.AZS_PATH) + + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': False, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + api_list = response.json.get(self.root_tag_list) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(2, len(api_list)) + + def test_get_all_not_authorized(self): + self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + self.create_availability_zone( + 'name2', 'description', self.azp.get('id'), True) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + response = self.get(self.AZS_PATH, status=403).json + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.NOT_AUTHORIZED_BODY, response) + + def test_update(self): + az_json = {'name': 'Fancy_Availability_Zone', + 'description': 'A great az. Pick me!', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body) + api_az = response.json.get(self.root_tag) + availability_zone_name = api_az.get('name') + + az_json = {'description': 'An even better az. Pick me!', + 'enabled': False} + body = self._build_body(az_json) + self.put(self.AZ_PATH.format(az_name=availability_zone_name), body) + + updated_az = self.get(self.AZ_PATH.format( + az_name=availability_zone_name)).json.get(self.root_tag) + self.assertEqual('An even better az. Pick me!', + updated_az.get('description')) + self.assertEqual(availability_zone_name, updated_az.get('name')) + self.assertEqual(self.azp.get('id'), + updated_az.get('availability_zone_profile_id')) + self.assertFalse(updated_az.get('enabled')) + + def test_update_deleted_name(self): + update_json = {'description': 'fake_desc'} + body = self._build_body(update_json) + response = self.put( + self.AZ_PATH.format(az_name=constants.NIL_UUID), body, + status=404) + self.assertEqual( + 'Availability Zone {} not found.'.format(constants.NIL_UUID), + response.json.get('faultstring')) + + def test_update_none(self): + az_json = {'name': 'Fancy_Availability_Zone', + 'description': 'A great az. Pick me!', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body) + api_az = response.json.get(self.root_tag) + availability_zone_name = api_az.get('name') + + az_json = {} + body = self._build_body(az_json) + self.put(self.AZ_PATH.format(az_name=availability_zone_name), body) + + updated_az = self.get(self.AZ_PATH.format( + az_name=availability_zone_name)).json.get(self.root_tag) + self.assertEqual('Fancy_Availability_Zone', updated_az.get('name')) + self.assertEqual('A great az. Pick me!', + updated_az.get('description')) + self.assertEqual(availability_zone_name, updated_az.get('name')) + self.assertEqual(self.azp.get('id'), + updated_az.get('availability_zone_profile_id')) + self.assertTrue(updated_az.get('enabled')) + + def test_update_availability_zone_profile_id(self): + az_json = {'name': 'Fancy_Availability_Zone', + 'description': 'A great az. Pick me!', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body) + api_az = response.json.get(self.root_tag) + availability_zone_name = api_az.get('name') + + az_json = {'availability_zone_profile_id': uuidutils.generate_uuid()} + body = self._build_body(az_json) + self.put(self.AZ_PATH.format(az_name=availability_zone_name), + body, status=400) + updated_az = self.get(self.AZ_PATH.format( + az_name=availability_zone_name)).json.get(self.root_tag) + self.assertEqual(self.azp.get('id'), + updated_az.get('availability_zone_profile_id')) + + def test_update_authorized(self): + az_json = {'name': 'Fancy_Availability_Zone', + 'description': 'A great az. Pick me!', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body) + api_az = response.json.get(self.root_tag) + availability_zone_name = api_az.get('name') + + az_json = {'description': 'An even better az. Pick me!', + 'enabled': False} + body = self._build_body(az_json) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + self.put(self.AZ_PATH.format(az_name=availability_zone_name), + body) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + + updated_az = self.get(self.AZ_PATH.format( + az_name=availability_zone_name)).json.get(self.root_tag) + self.assertEqual('An even better az. Pick me!', + updated_az.get('description')) + self.assertEqual(availability_zone_name, updated_az.get('name')) + self.assertEqual(self.azp.get('id'), + updated_az.get('availability_zone_profile_id')) + self.assertFalse(updated_az.get('enabled')) + + def test_update_not_authorized(self): + az_json = {'name': 'Fancy_Availability_Zone', + 'description': 'A great az. Pick me!', + 'availability_zone_profile_id': self.azp.get('id')} + body = self._build_body(az_json) + response = self.post(self.AZS_PATH, body) + api_az = response.json.get(self.root_tag) + availability_zone_name = api_az.get('name') + + az_json = {'description': 'An even better az. Pick me!', + 'enabled': False} + body = self._build_body(az_json) + + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + self.put(self.AZ_PATH.format(az_name=availability_zone_name), + body, status=403) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + + updated_az = self.get(self.AZ_PATH.format( + az_name=availability_zone_name)).json.get(self.root_tag) + self.assertEqual('A great az. Pick me!', + updated_az.get('description')) + self.assertEqual(availability_zone_name, updated_az.get('name')) + self.assertEqual(self.azp.get('id'), + updated_az.get('availability_zone_profile_id')) + self.assertTrue(updated_az.get('enabled')) + + @mock.patch('octavia.db.repositories.AvailabilityZoneRepository.update') + def test_update_exception(self, mock_update): + mock_update.side_effect = [exceptions.OctaviaException()] + update_json = {'description': 'Some availability zone.'} + body = self._build_body(update_json) + response = self.put(self.AZ_PATH.format(az_name='bogus'), body, + status=500) + self.assertEqual('An unknown exception occurred.', + response.json.get('faultstring')) + + def test_delete(self): + az = self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + self.delete(self.AZ_PATH.format(az_name=az.get('name'))) + response = self.get(self.AZ_PATH.format(az_name=az.get('name')), + status=404) + err_msg = "Availability Zone %s not found." % az.get('name') + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_delete_nonexistent_name(self): + response = self.delete( + self.AZ_PATH.format(az_name='bogus_name'), status=404) + self.assertEqual('Availability Zone bogus_name not found.', + response.json.get('faultstring')) + + def test_delete_deleted_name(self): + response = self.delete( + self.AZ_PATH.format(az_name=constants.NIL_UUID), status=404) + self.assertEqual( + 'Availability Zone {} not found.'.format(constants.NIL_UUID), + response.json.get('faultstring')) + + def test_delete_authorized(self): + az = self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + self.delete( + self.AZ_PATH.format(az_name=az.get('name'))) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + response = self.get(self.AZ_PATH.format(az_name=az.get('name')), + status=404) + err_msg = "Availability Zone %s not found." % az.get('name') + self.assertEqual(err_msg, response.json.get('faultstring')) + + def test_delete_not_authorized(self): + az = self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + + response = self.delete(self.AZ_PATH.format(az_name=az.get('name')), + status=403) + api_az = response.json + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.NOT_AUTHORIZED_BODY, api_az) + + response = self.get(self.AZ_PATH.format( + az_name=az.get('name'))).json.get(self.root_tag) + self.assertEqual('name1', response.get('name')) + + def test_delete_in_use(self): + # TODO(sorrison): Enable this test + self.skipTest("Enable in next patch when LB can use AZ") + az = self.create_availability_zone( + 'name1', 'description', self.azp.get('id'), True) + project_id = uuidutils.generate_uuid() + lb_id = uuidutils.generate_uuid() + self.create_load_balancer(lb_id, name='lb1', + project_id=project_id, + description='desc1', + availability_zone_name=az.get('name'), + admin_state_up=False) + self.delete(self.AZ_PATH.format(az_name=az.get('name')), + status=409) + response = self.get(self.AZ_PATH.format( + az_name=az.get('name'))).json.get(self.root_tag) + self.assertEqual('name1', response.get('name')) + + @mock.patch('octavia.db.repositories.AvailabilityZoneRepository.delete') + def test_delete_exception(self, mock_delete): + mock_delete.side_effect = [exceptions.OctaviaException()] + response = self.delete(self.AZ_PATH.format(az_name='bogus'), + status=500) + self.assertEqual('An unknown exception occurred.', + response.json.get('faultstring')) diff --git a/octavia/tests/functional/api/v2/test_flavor_profiles.py b/octavia/tests/functional/api/v2/test_flavor_profiles.py index 0c0f38ae02..f9cd1cbf52 100644 --- a/octavia/tests/functional/api/v2/test_flavor_profiles.py +++ b/octavia/tests/functional/api/v2/test_flavor_profiles.py @@ -32,8 +32,8 @@ class TestFlavorProfiles(base.BaseAPITest): def _assert_request_matches_response(self, req, resp, **optionals): self.assertTrue(uuidutils.is_uuid_like(resp.get('id'))) self.assertEqual(req.get('name'), resp.get('name')) - self.assertEqual(req.get('provider_name'), - resp.get('provider_name')) + self.assertEqual(req.get(constants.PROVIDER_NAME), + resp.get(constants.PROVIDER_NAME)) self.assertEqual(req.get(constants.FLAVOR_DATA), resp.get(constants.FLAVOR_DATA)) @@ -43,7 +43,7 @@ class TestFlavorProfiles(base.BaseAPITest): self.assertEqual([], api_list) def test_create(self): - fp_json = {'name': 'test1', 'provider_name': 'noop_driver', + fp_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(fp_json) response = self.post(self.FPS_PATH, body) @@ -51,7 +51,8 @@ class TestFlavorProfiles(base.BaseAPITest): self._assert_request_matches_response(fp_json, api_fp) def test_create_with_missing_name(self): - fp_json = {'provider_name': 'pr1', constants.FLAVOR_DATA: '{"x": "y"}'} + fp_json = {constants.PROVIDER_NAME: 'pr1', + constants.FLAVOR_DATA: '{"x": "y"}'} body = self._build_body(fp_json) response = self.post(self.FPS_PATH, body, status=400) err_msg = ("Invalid input for field/attribute name. Value: " @@ -67,7 +68,7 @@ class TestFlavorProfiles(base.BaseAPITest): self.assertEqual(err_msg, response.json.get('faultstring')) def test_create_with_missing_flavor_data(self): - fp_json = {'name': 'xyz', 'provider_name': 'pr1'} + fp_json = {'name': 'xyz', constants.PROVIDER_NAME: 'pr1'} body = self._build_body(fp_json) response = self.post(self.FPS_PATH, body, status=400) err_msg = ("Invalid input for field/attribute flavor_data. " @@ -75,7 +76,7 @@ class TestFlavorProfiles(base.BaseAPITest): self.assertEqual(err_msg, response.json.get('faultstring')) def test_create_with_empty_flavor_data(self): - fp_json = {'name': 'test1', 'provider_name': 'noop_driver', + fp_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', constants.FLAVOR_DATA: '{}'} body = self._build_body(fp_json) response = self.post(self.FPS_PATH, body) @@ -83,25 +84,25 @@ class TestFlavorProfiles(base.BaseAPITest): self._assert_request_matches_response(fp_json, api_fp) def test_create_with_long_name(self): - fp_json = {'name': 'n' * 256, 'provider_name': 'test1', + fp_json = {'name': 'n' * 256, constants.PROVIDER_NAME: 'test1', constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(fp_json) self.post(self.FPS_PATH, body, status=400) def test_create_with_long_provider(self): - fp_json = {'name': 'name1', 'provider_name': 'n' * 256, + fp_json = {'name': 'name1', constants.PROVIDER_NAME: 'n' * 256, constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(fp_json) self.post(self.FPS_PATH, body, status=400) def test_create_with_long_flavor_data(self): - fp_json = {'name': 'name1', 'provider_name': 'amp', + fp_json = {'name': 'name1', constants.PROVIDER_NAME: 'amp', constants.FLAVOR_DATA: 'n' * 4097} body = self._build_body(fp_json) self.post(self.FPS_PATH, body, status=400) def test_create_authorized(self): - fp_json = {'name': 'test1', 'provider_name': 'noop_driver', + fp_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(fp_json) self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) @@ -136,7 +137,8 @@ class TestFlavorProfiles(base.BaseAPITest): auth_strategy = self.conf.conf.api_settings.get('auth_strategy') self.conf.config(group='api_settings', auth_strategy=constants.TESTING) fp_json = {'name': 'name', - 'provider_name': 'xyz', constants.FLAVOR_DATA: '{"x": "y"}'} + constants.PROVIDER_NAME: 'xyz', + constants.FLAVOR_DATA: '{"x": "y"}'} body = self._build_body(fp_json) response = self.post(self.FPS_PATH, body, status=403) api_fp = response.json @@ -144,7 +146,7 @@ class TestFlavorProfiles(base.BaseAPITest): self.assertEqual(self.NOT_AUTHORIZED_BODY, api_fp) def test_create_db_failure(self): - fp_json = {'name': 'test1', 'provider_name': 'noop_driver', + fp_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(fp_json) with mock.patch("octavia.db.repositories.FlavorProfileRepository." @@ -156,7 +158,7 @@ class TestFlavorProfiles(base.BaseAPITest): self.post(self.FPS_PATH, body, status=409) def test_create_with_invalid_json(self): - fp_json = {'name': 'test1', 'provider_name': 'noop_driver', + fp_json = {'name': 'test1', constants.PROVIDER_NAME: 'noop_driver', constants.FLAVOR_DATA: '{hello: "world"}'} body = self._build_body(fp_json) self.post(self.FPS_PATH, body, status=400) @@ -183,10 +185,11 @@ class TestFlavorProfiles(base.BaseAPITest): self.assertTrue(uuidutils.is_uuid_like(fp.get('id'))) response = self.get( self.FP_PATH.format(fp_id=fp.get('id')), params={ - 'fields': ['id', 'provider_name']}).json.get(self.root_tag) + 'fields': ['id', constants.PROVIDER_NAME]} + ).json.get(self.root_tag) self.assertEqual(fp.get('id'), response.get('id')) self.assertIn(u'id', response) - self.assertIn(u'provider_name', response) + self.assertIn(constants.PROVIDER_NAME, response) self.assertNotIn(u'name', response) self.assertNotIn(constants.FLAVOR_DATA, response) @@ -238,13 +241,13 @@ class TestFlavorProfiles(base.BaseAPITest): '{"image": "ubuntu"}') ref_fp_1 = {u'flavor_data': u'{"image": "ubuntu"}', u'id': fp1.get('id'), u'name': u'test1', - u'provider_name': u'noop_driver'} + constants.PROVIDER_NAME: u'noop_driver'} self.assertTrue(uuidutils.is_uuid_like(fp1.get('id'))) fp2 = self.create_flavor_profile('test2', 'noop_driver-alt', '{"image": "ubuntu"}') ref_fp_2 = {u'flavor_data': u'{"image": "ubuntu"}', u'id': fp2.get('id'), u'name': u'test2', - u'provider_name': u'noop_driver-alt'} + constants.PROVIDER_NAME: u'noop_driver-alt'} self.assertTrue(uuidutils.is_uuid_like(fp2.get('id'))) response = self.get(self.FPS_PATH) @@ -268,7 +271,7 @@ class TestFlavorProfiles(base.BaseAPITest): for profile in api_list: self.assertIn(u'id', profile) self.assertIn(u'name', profile) - self.assertNotIn(u'provider_name', profile) + self.assertNotIn(constants.PROVIDER_NAME, profile) self.assertNotIn(constants.FLAVOR_DATA, profile) def test_get_all_authorized(self): @@ -323,14 +326,15 @@ class TestFlavorProfiles(base.BaseAPITest): fp = self.create_flavor_profile('test_profile', 'noop_driver', '{"x": "y"}') update_data = {'name': 'the_profile', - 'provider_name': 'noop_driver-alt', + constants.PROVIDER_NAME: 'noop_driver-alt', constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(update_data) response = self.put(self.FP_PATH.format(fp_id=fp.get('id')), body) response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('the_profile', response.get('name')) - self.assertEqual('noop_driver-alt', response.get('provider_name')) + self.assertEqual('noop_driver-alt', + response.get(constants.PROVIDER_NAME)) self.assertEqual('{"hello": "world"}', response.get(constants.FLAVOR_DATA)) @@ -350,7 +354,7 @@ class TestFlavorProfiles(base.BaseAPITest): response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('test_profile', response.get('name')) - self.assertEqual('noop_driver', response.get('provider_name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) self.assertEqual('{"x": "y"}', response.get(constants.FLAVOR_DATA)) @@ -377,20 +381,21 @@ class TestFlavorProfiles(base.BaseAPITest): fp = self.create_flavor_profile('test_profile', 'noop_driver', '{"x": "y"}') update_data = {'name': 'the_profile', - 'provider_name': 'noop_driver-alt'} + constants.PROVIDER_NAME: 'noop_driver-alt'} body = self._build_body(update_data) response = self.put(self.FP_PATH.format(fp_id=fp.get('id')), body) response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('the_profile', response.get('name')) - self.assertEqual('noop_driver-alt', response.get('provider_name')) + self.assertEqual('noop_driver-alt', + response.get(constants.PROVIDER_NAME)) self.assertEqual('{"x": "y"}', response.get(constants.FLAVOR_DATA)) def test_update_authorized(self): fp = self.create_flavor_profile('test_profile', 'noop_driver', '{"x": "y"}') update_data = {'name': 'the_profile', - 'provider_name': 'noop_driver-alt', + constants.PROVIDER_NAME: 'noop_driver-alt', constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(update_data) self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) @@ -421,14 +426,15 @@ class TestFlavorProfiles(base.BaseAPITest): response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('the_profile', response.get('name')) - self.assertEqual('noop_driver-alt', response.get('provider_name')) + self.assertEqual('noop_driver-alt', + response.get(constants.PROVIDER_NAME)) self.assertEqual('{"hello": "world"}', response.get(constants.FLAVOR_DATA)) def test_update_not_authorized(self): fp = self.create_flavor_profile('test_profile', 'noop_driver', '{"x": "y"}') - update_data = {'name': 'the_profile', 'provider_name': 'amp', + update_data = {'name': 'the_profile', constants.PROVIDER_NAME: 'amp', constants.FLAVOR_DATA: '{"hello": "world"}'} body = self._build_body(update_data) self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) @@ -440,7 +446,7 @@ class TestFlavorProfiles(base.BaseAPITest): response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('test_profile', response.get('name')) - self.assertEqual('noop_driver', response.get('provider_name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) self.assertEqual('{"x": "y"}', response.get(constants.FLAVOR_DATA)) @@ -451,7 +457,7 @@ class TestFlavorProfiles(base.BaseAPITest): # Test updating provider while in use is not allowed update_data = {'name': 'the_profile', - 'provider_name': 'noop_driver-alt'} + constants.PROVIDER_NAME: 'noop_driver-alt'} body = self._build_body(update_data) response = self.put(self.FP_PATH.format(fp_id=fp.get('id')), body, status=409) @@ -461,7 +467,7 @@ class TestFlavorProfiles(base.BaseAPITest): response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('test_profile', response.get('name')) - self.assertEqual('noop_driver', response.get('provider_name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) self.assertEqual('{"x": "y"}', response.get(constants.FLAVOR_DATA)) # Test updating flavor data while in use is not allowed @@ -476,7 +482,7 @@ class TestFlavorProfiles(base.BaseAPITest): response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('test_profile', response.get('name')) - self.assertEqual('noop_driver', response.get('provider_name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) self.assertEqual('{"x": "y"}', response.get(constants.FLAVOR_DATA)) # Test that you can still update the name when in use @@ -486,7 +492,7 @@ class TestFlavorProfiles(base.BaseAPITest): response = self.get( self.FP_PATH.format(fp_id=fp.get('id'))).json.get(self.root_tag) self.assertEqual('the_profile', response.get('name')) - self.assertEqual('noop_driver', response.get('provider_name')) + self.assertEqual('noop_driver', response.get(constants.PROVIDER_NAME)) self.assertEqual('{"x": "y"}', response.get(constants.FLAVOR_DATA)) def test_delete(self): diff --git a/octavia/tests/functional/api/v2/test_flavors.py b/octavia/tests/functional/api/v2/test_flavors.py index 00ad005e6b..3117617476 100644 --- a/octavia/tests/functional/api/v2/test_flavors.py +++ b/octavia/tests/functional/api/v2/test_flavors.py @@ -245,11 +245,6 @@ class TestFlavors(base.BaseAPITest): self.assertEqual(self.NOT_AUTHORIZED_BODY, response) def test_get_all(self): - ref_flavor_1 = { - u'description': u'description', u'enabled': True, - u'flavor_profile_id': u'd21bf20d-c323-4004-bf67-f90591ceced9', - u'id': u'172ccb10-a3b7-4c73-aee8-bdb77fb51ed5', - u'name': u'name1'} flavor1 = self.create_flavor('name1', 'description', self.fp.get('id'), True) self.assertTrue(uuidutils.is_uuid_like(flavor1.get('id'))) diff --git a/octavia/tests/functional/api/v2/test_provider.py b/octavia/tests/functional/api/v2/test_provider.py index a59e8c3a89..49b431b12e 100644 --- a/octavia/tests/functional/api/v2/test_provider.py +++ b/octavia/tests/functional/api/v2/test_provider.py @@ -171,3 +171,144 @@ class TestFlavorCapabilities(base.BaseAPITest): self.assertEqual(1, len(capabilities)) self.assertEqual(1, len(capabilities[0])) self.assertEqual('compute_flavor', capabilities[0][constants.NAME]) + + +class TestAvailabilityZoneCapabilities(base.BaseAPITest): + + root_tag = 'availability_zone_capabilities' + + def setUp(self): + super(TestAvailabilityZoneCapabilities, self).setUp() + + def test_nonexistent_provider(self): + self.get(self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider='bogus'), status=400) + + def test_noop_provider(self): + ref_capabilities = [{'description': 'The compute availability zone to ' + 'use for this loadbalancer.', + 'name': constants.COMPUTE_ZONE}] + + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider='noop_driver')) + self.assertEqual(ref_capabilities, result.json.get(self.root_tag)) + + def test_amphora_driver(self): + ref_description1 = 'The compute availability zone.' + ref_description2 = 'The management network ID for the amphora.' + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider='amphora')) + capabilities = result.json.get(self.root_tag) + capability_dict = [i for i in capabilities if + i['name'] == constants.COMPUTE_ZONE][0] + self.assertEqual(ref_description1, + capability_dict['description']) + capability_dict = [i for i in capabilities if + i['name'] == constants.MANAGEMENT_NETWORK][0] + self.assertEqual(ref_description2, + capability_dict['description']) + + # Some drivers might not have implemented this yet, test that case + @mock.patch('octavia.api.drivers.noop_driver.driver.NoopProviderDriver.' + 'get_supported_availability_zone_metadata') + def test_not_implemented(self, mock_get_metadata): + mock_get_metadata.side_effect = exceptions.NotImplementedError() + self.get(self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider='noop_driver'), status=501) + + def test_authorized(self): + ref_capabilities = [{'description': 'The compute availability zone to ' + 'use for this loadbalancer.', + 'name': constants.COMPUTE_ZONE}] + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + project_id = uuidutils.generate_uuid() + with mock.patch.object(octavia.common.context.Context, 'project_id', + project_id): + override_credentials = { + 'service_user_id': None, + 'user_domain_id': None, + 'is_admin_project': True, + 'service_project_domain_id': None, + 'service_project_id': None, + 'roles': ['load-balancer_member'], + 'user_id': None, + 'is_admin': True, + 'service_user_domain_id': None, + 'project_domain_id': None, + 'service_roles': [], + 'project_id': project_id} + with mock.patch( + "oslo_context.context.RequestContext.to_policy_values", + return_value=override_credentials): + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider='noop_driver')) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(ref_capabilities, result.json.get(self.root_tag)) + + def test_not_authorized(self): + self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) + auth_strategy = self.conf.conf.api_settings.get('auth_strategy') + self.conf.config(group='api_settings', auth_strategy=constants.TESTING) + self.get(self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider='noop_driver'), status=403) + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + + def test_amphora_driver_one_filter(self): + ref_description = 'The compute availability zone.' + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider=constants.AMPHORA), + params={constants.NAME: constants.COMPUTE_ZONE}) + capabilities = result.json.get(self.root_tag) + self.assertEqual(1, len(capabilities)) + self.assertEqual(2, len(capabilities[0])) + self.assertEqual(ref_description, + capabilities[0][constants.DESCRIPTION]) + + ref_description = 'The management network ID for the amphora.' + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider=constants.AMPHORA), + params={constants.NAME: constants.MANAGEMENT_NETWORK}) + capabilities = result.json.get(self.root_tag) + self.assertEqual(1, len(capabilities)) + self.assertEqual(2, len(capabilities[0])) + self.assertEqual(ref_description, + capabilities[0][constants.DESCRIPTION]) + + def test_amphora_driver_two_filters(self): + ref_description = 'The compute availability zone.' + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider=constants.AMPHORA), + params={constants.NAME: constants.COMPUTE_ZONE, + constants.DESCRIPTION: ref_description}) + capabilities = result.json.get(self.root_tag) + self.assertEqual(1, len(capabilities)) + self.assertEqual(ref_description, + capabilities[0][constants.DESCRIPTION]) + + def test_amphora_driver_filter_no_match(self): + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider=constants.AMPHORA), + params={constants.NAME: 'bogus'}) + capabilities = result.json.get(self.root_tag) + self.assertEqual([], capabilities) + + def test_amphora_driver_one_filter_one_field(self): + result = self.get( + self.AVAILABILITY_ZONE_CAPABILITIES_PATH.format( + provider=constants.AMPHORA), + params={constants.NAME: constants.COMPUTE_ZONE, + constants.FIELDS: constants.NAME}) + capabilities = result.json.get(self.root_tag) + self.assertEqual(1, len(capabilities)) + self.assertEqual(1, len(capabilities[0])) + self.assertEqual(constants.COMPUTE_ZONE, + capabilities[0][constants.NAME]) diff --git a/octavia/tests/functional/db/base.py b/octavia/tests/functional/db/base.py index d687bcc14a..b2e605f57f 100644 --- a/octavia/tests/functional/db/base.py +++ b/octavia/tests/functional/db/base.py @@ -108,6 +108,18 @@ class OctaviaDBTestBase(test_base.DbTestCase): description='Placeholder for DELETED LBs with DELETED flavors') session.add(deleted_flavor) session.flush() + deleted_az_profile = models.AvailabilityZoneProfile( + id=constants.NIL_UUID, name='DELETED-PLACEHOLDER', + provider_name=constants.DELETED, availability_zone_data='{}') + session.add(deleted_az_profile) + session.flush() + deleted_az = models.AvailabilityZone( + availability_zone_profile_id=constants.NIL_UUID, + name=constants.NIL_UUID, enabled=False, + description='Placeholder for DELETED LBs with DELETED ' + 'availability zones') + session.add(deleted_az) + session.flush() def _seed_lookup_table(self, session, name_list, model_cls): for name in name_list: diff --git a/octavia/tests/functional/db/test_repositories.py b/octavia/tests/functional/db/test_repositories.py index 5ea1a0fbd4..fb17e09c9a 100644 --- a/octavia/tests/functional/db/test_repositories.py +++ b/octavia/tests/functional/db/test_repositories.py @@ -121,7 +121,8 @@ class AllRepositoriesTest(base.OctaviaDBTestBase): 'amphorahealth', 'vrrpgroup', 'l7rule', 'l7policy', 'amp_build_slots', 'amp_build_req', 'quotas', 'flavor', 'flavor_profile', 'spares_pool', - 'listener_cidr') + 'listener_cidr', 'availability_zone', + 'availability_zone_profile') for repo_attr in repo_attr_names: single_repo = getattr(self.repos, repo_attr, None) message = ("Class Repositories should have %s instance" diff --git a/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py b/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py index a05f06bbf0..93a42a1347 100644 --- a/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py +++ b/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py @@ -648,3 +648,45 @@ class TestAmphoraDriver(base.TestRpc): 'SUPPORTED_FLAVOR_SCHEMA', 'bogus'): self.assertRaises(exceptions.DriverError, self.amp_driver.validate_flavor, 'bogus') + + # Availability Zone + def test_get_supported_availability_zone_metadata(self): + test_schema = { + "properties": { + "test_name": {"description": "Test description"}, + "test_name2": {"description": "Another description"}}} + ref_dict = {"test_name": "Test description", + "test_name2": "Another description"} + + # mock out the supported_availability_zone_metadata + with mock.patch('octavia.api.drivers.amphora_driver.' + 'availability_zone_schema.' + 'SUPPORTED_AVAILABILITY_ZONE_SCHEMA', test_schema): + result = self.amp_driver.get_supported_availability_zone_metadata() + self.assertEqual(ref_dict, result) + + # Test for bad schema + with mock.patch('octavia.api.drivers.amphora_driver.' + 'availability_zone_schema.' + 'SUPPORTED_AVAILABILITY_ZONE_SCHEMA', 'bogus'): + self.assertRaises( + exceptions.DriverError, + self.amp_driver.get_supported_availability_zone_metadata) + + def test_validate_availability_zone(self): + ref_dict = {consts.COMPUTE_ZONE: 'my_compute_zone'} + self.amp_driver.validate_availability_zone(ref_dict) + + # Test bad availability zone metadata key + ref_dict = {'bogus': 'bogus'} + self.assertRaises(exceptions.UnsupportedOptionError, + self.amp_driver.validate_availability_zone, + ref_dict) + + # Test for bad schema + with mock.patch('octavia.api.drivers.amphora_driver.' + 'availability_zone_schema.' + 'SUPPORTED_AVAILABILITY_ZONE_SCHEMA', 'bogus'): + self.assertRaises(exceptions.DriverError, + self.amp_driver.validate_availability_zone, + 'bogus') diff --git a/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py b/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py index b53a2612c0..d3a5e983f8 100644 --- a/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py +++ b/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py @@ -648,3 +648,45 @@ class TestAmphoraDriver(base.TestRpc): 'SUPPORTED_FLAVOR_SCHEMA', 'bogus'): self.assertRaises(exceptions.DriverError, self.amp_driver.validate_flavor, 'bogus') + + # Availability Zone + def test_get_supported_availability_zone_metadata(self): + test_schema = { + "properties": { + "test_name": {"description": "Test description"}, + "test_name2": {"description": "Another description"}}} + ref_dict = {"test_name": "Test description", + "test_name2": "Another description"} + + # mock out the supported_availability_zone_metadata + with mock.patch('octavia.api.drivers.amphora_driver.' + 'availability_zone_schema.' + 'SUPPORTED_AVAILABILITY_ZONE_SCHEMA', test_schema): + result = self.amp_driver.get_supported_availability_zone_metadata() + self.assertEqual(ref_dict, result) + + # Test for bad schema + with mock.patch('octavia.api.drivers.amphora_driver.' + 'availability_zone_schema.' + 'SUPPORTED_AVAILABILITY_ZONE_SCHEMA', 'bogus'): + self.assertRaises( + exceptions.DriverError, + self.amp_driver.get_supported_availability_zone_metadata) + + def test_validate_availability_zone(self): + ref_dict = {consts.COMPUTE_ZONE: 'my_compute_zone'} + self.amp_driver.validate_availability_zone(ref_dict) + + # Test bad availability zone metadata key + ref_dict = {'bogus': 'bogus'} + self.assertRaises(exceptions.UnsupportedOptionError, + self.amp_driver.validate_availability_zone, + ref_dict) + + # Test for bad schema + with mock.patch('octavia.api.drivers.amphora_driver.' + 'availability_zone_schema.' + 'SUPPORTED_AVAILABILITY_ZONE_SCHEMA', 'bogus'): + self.assertRaises(exceptions.DriverError, + self.amp_driver.validate_availability_zone, + 'bogus') diff --git a/octavia/tests/unit/api/drivers/test_provider_noop_driver.py b/octavia/tests/unit/api/drivers/test_provider_noop_driver.py index d3fe818888..5a5096baee 100644 --- a/octavia/tests/unit/api/drivers/test_provider_noop_driver.py +++ b/octavia/tests/unit/api/drivers/test_provider_noop_driver.py @@ -143,6 +143,9 @@ class TestNoopProviderDriver(base.TestCase): self.ref_flavor_metadata = {"amp_image_tag": "The glance image tag " "to use for this load balancer."} + self.ref_availability_zone_metadata = { + "compute_zone": "The compute availability zone to use for this " + "loadbalancer."} def test_create_vip_port(self): vip_dict = self.driver.create_vip_port(self.loadbalancer_id, @@ -304,3 +307,17 @@ class TestNoopProviderDriver(base.TestCase): flavor_hash = hash(frozenset(self.ref_flavor_metadata)) self.assertEqual((self.ref_flavor_metadata, 'validate_flavor'), self.driver.driver.driverconfig[flavor_hash]) + + def test_get_supported_availability_zone_metadata(self): + metadata = self.driver.get_supported_availability_zone_metadata() + + self.assertEqual(self.ref_availability_zone_metadata, metadata) + + def test_validate_availability_zone(self): + self.driver.validate_availability_zone( + self.ref_availability_zone_metadata) + + az_hash = hash(frozenset(self.ref_availability_zone_metadata)) + self.assertEqual((self.ref_availability_zone_metadata, + 'validate_availability_zone'), + self.driver.driver.driverconfig[az_hash]) diff --git a/octavia/tests/unit/api/v2/types/test_availability_zone_profiles.py b/octavia/tests/unit/api/v2/types/test_availability_zone_profiles.py new file mode 100644 index 0000000000..45d95db093 --- /dev/null +++ b/octavia/tests/unit/api/v2/types/test_availability_zone_profiles.py @@ -0,0 +1,70 @@ +# Copyright 2019 Verizon Media +# +# 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 wsme import exc +from wsme.rest import json as wsme_json + +from octavia.api.v2.types import availability_zone_profile as azp_type +from octavia.common import constants +from octavia.tests.unit.api.common import base + + +class TestAvailabilityZoneProfile(object): + + _type = None + + def test_availability_zone_profile(self): + body = {"name": "test_name", "provider_name": "test1", + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + availability_zone = wsme_json.fromjson(self._type, body) + self.assertEqual(availability_zone.name, body["name"]) + + def test_invalid_name(self): + body = {"name": 0} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_name_length(self): + body = {"name": "x" * 256} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_provider_name_length(self): + body = {"name": "x" * 250, + "provider_name": "X" * 256} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, + self._type, body) + + def test_name_mandatory(self): + body = {"provider_name": "test1", + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_provider_name_mandatory(self): + body = {"name": "test_name", + constants.AVAILABILITY_ZONE_DATA: '{"hello": "world"}'} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_meta_mandatory(self): + body = {"name": "test_name", "provider_name": "test1"} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + +class TestAvailabilityZoneProfilePOST(base.BaseTypesTest, + TestAvailabilityZoneProfile): + + _type = azp_type.AvailabilityZoneProfilePOST diff --git a/octavia/tests/unit/api/v2/types/test_availability_zones.py b/octavia/tests/unit/api/v2/types/test_availability_zones.py new file mode 100644 index 0000000000..3b42a1820b --- /dev/null +++ b/octavia/tests/unit/api/v2/types/test_availability_zones.py @@ -0,0 +1,87 @@ +# Copyright 2017 Walmart Stores Inc. +# +# 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 oslo_utils import uuidutils +from wsme import exc +from wsme.rest import json as wsme_json + +from octavia.api.v2.types import availability_zones as availability_zone_type +from octavia.tests.unit.api.common import base + + +class TestAvailabilityZone(object): + + _type = None + + def test_availability_zone(self): + body = {"name": "test_name", "description": "test_description", + "availability_zone_profile_id": uuidutils.generate_uuid()} + availability_zone = wsme_json.fromjson(self._type, body) + self.assertTrue(availability_zone.enabled) + + def test_invalid_name(self): + body = {"name": 0, + "availability_zone_profile_id": uuidutils.generate_uuid()} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_name_length(self): + body = {"name": "x" * 256, + "availability_zone_profile_id": uuidutils.generate_uuid()} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_invalid_description(self): + body = {"availability_zone_profile_id": uuidutils.generate_uuid(), + "description": 0, "name": "test"} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_description_length(self): + body = {"name": "x" * 250, + "availability_zone_profile_id": uuidutils.generate_uuid(), + "description": "0" * 256} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_invalid_enabled(self): + body = {"name": "test_name", + "availability_zone_profile_id": uuidutils.generate_uuid(), + "enabled": "notvalid"} + self.assertRaises(ValueError, wsme_json.fromjson, self._type, + body) + + def test_name_mandatory(self): + body = {"description": "xyz", + "availability_zone_profile_id": uuidutils.generate_uuid(), + "enabled": True} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + def test_availability_zone_profile_id_mandatory(self): + body = {"name": "test_name"} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + + +class TestAvailabilityZonePOST(base.BaseTypesTest, TestAvailabilityZone): + + _type = availability_zone_type.AvailabilityZonePOST + + def test_non_uuid_project_id(self): + body = {"name": "test_name", "description": "test_description", + "availability_zone_profile_id": uuidutils.generate_uuid()} + lb = wsme_json.fromjson(self._type, body) + self.assertEqual(lb.availability_zone_profile_id, + body['availability_zone_profile_id']) diff --git a/octavia/tests/unit/compute/drivers/test_compute_noop_driver.py b/octavia/tests/unit/compute/drivers/test_compute_noop_driver.py index 6989734f9d..27e4cf4ddf 100644 --- a/octavia/tests/unit/compute/drivers/test_compute_noop_driver.py +++ b/octavia/tests/unit/compute/drivers/test_compute_noop_driver.py @@ -49,6 +49,8 @@ class TestNoopComputeDriver(base.TestCase): self.port_id = 88 self.network_id = uuidutils.generate_uuid() self.ip_address = "192.0.2.2" + self.flavor_id = uuidutils.generate_uuid() + self.availability_zone = 'my_test_az' def test_build(self): self.driver.build(self.name, self.amphora_flavor, @@ -120,3 +122,14 @@ class TestNoopComputeDriver(base.TestCase): 'detach_port'), self.driver.driver.computeconfig[( self.amphora_id, self.port_id)]) + + def test_validate_flavor(self): + self.driver.validate_flavor(self.flavor_id) + self.assertEqual((self.flavor_id, 'validate_flavor'), + self.driver.driver.computeconfig[self.flavor_id]) + + def test_validate_availability_zone(self): + self.driver.validate_availability_zone(self.availability_zone) + self.assertEqual( + (self.availability_zone, 'validate_availability_zone'), + self.driver.driver.computeconfig[self.availability_zone]) diff --git a/octavia/tests/unit/compute/drivers/test_nova_driver.py b/octavia/tests/unit/compute/drivers/test_nova_driver.py index 995b25ce36..5e5ca7e8d8 100644 --- a/octavia/tests/unit/compute/drivers/test_nova_driver.py +++ b/octavia/tests/unit/compute/drivers/test_nova_driver.py @@ -128,7 +128,7 @@ class TestNovaClient(base.TestCase): self.manager.server_groups = mock.MagicMock() self.manager._nova_client = mock.MagicMock() self.manager.flavor_manager = mock.MagicMock() - self.manager.flavor_manager.get = mock.MagicMock() + self.manager.availability_zone_manager = mock.MagicMock() self.nova_response.interface_list.side_effect = [[self.interface_list]] self.manager.manager.get.return_value = self.nova_response @@ -156,6 +156,7 @@ class TestNovaClient(base.TestCase): self.compute_id = uuidutils.generate_uuid() self.network_id = uuidutils.generate_uuid() self.flavor_id = uuidutils.generate_uuid() + self.availability_zone = 'my_test_az' super(TestNovaClient, self).setUp() @@ -444,3 +445,17 @@ class TestNovaClient(base.TestCase): self.assertRaises(exceptions.OctaviaException, self.manager.validate_flavor, "bogus") + + def test_validate_availability_zone(self): + mock_az = mock.Mock() + mock_az.zoneName = self.availability_zone + self.manager.availability_zone_manager.list.return_value = [mock_az] + self.manager.validate_availability_zone(self.availability_zone) + self.manager.availability_zone_manager.list.assert_called_with( + detailed=False) + + def test_validate_availability_zone_with_exception(self): + self.manager.availability_zone_manager.list.return_value = [] + self.assertRaises(exceptions.InvalidSubresource, + self.manager.validate_availability_zone, + "bogus") diff --git a/releasenotes/notes/availability-zone-api-a28ff5e00bdcc69a.yaml b/releasenotes/notes/availability-zone-api-a28ff5e00bdcc69a.yaml new file mode 100644 index 0000000000..70ae88208f --- /dev/null +++ b/releasenotes/notes/availability-zone-api-a28ff5e00bdcc69a.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add an API for allowing administrators to manage Octavia Availability + Zones and Availability Zone Profiles, which behave nearly identically + to Flavors and Flavor Profiles.