From ac0130bf0facbbeaf95ec1926c3f3e0860604490 Mon Sep 17 00:00:00 2001 From: liusheng Date: Mon, 17 Apr 2017 18:41:13 +0800 Subject: [PATCH] Add keypair support This change add keypair feature support. Change-Id: I0ec5d625a2c5a8cc17f56fe3e2462127abb94000 --- api-ref/source/v1/keypairs.inc | 155 ++++++++++++++++ api-ref/source/v1/parameters.yaml | 65 +++++++ .../v1/samples/keypairs/keypair-get-resp.json | 19 ++ .../keypairs/keypair-import-post-req.json | 4 + .../keypairs/keypair-import-post-resp.json | 19 ++ .../samples/keypairs/keypair-list-resp.json | 23 +++ .../v1/samples/keypairs/keypair-post-req.json | 5 + .../samples/keypairs/keypair-post-resp.json | 20 +++ mogan/api/controllers/v1/__init__.py | 13 ++ mogan/api/controllers/v1/keypairs.py | 165 ++++++++++++++++++ mogan/api/controllers/v1/schemas/keypairs.py | 32 ++++ mogan/common/exception.py | 11 ++ mogan/common/utils.py | 128 +++++++++++++- mogan/db/api.py | 25 +++ .../91941bf1ebc9_initial_migration.py | 16 ++ mogan/db/sqlalchemy/api.py | 32 ++++ mogan/db/sqlalchemy/models.py | 19 ++ mogan/engine/api.py | 78 +++++++++ mogan/objects/__init__.py | 1 + mogan/objects/keypair.py | 103 +++++++++++ mogan/tests/unit/objects/test_keypairs.py | 83 +++++++++ mogan/tests/unit/objects/test_objects.py | 2 + requirements.txt | 1 + 23 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 api-ref/source/v1/keypairs.inc create mode 100644 api-ref/source/v1/samples/keypairs/keypair-get-resp.json create mode 100644 api-ref/source/v1/samples/keypairs/keypair-import-post-req.json create mode 100644 api-ref/source/v1/samples/keypairs/keypair-import-post-resp.json create mode 100644 api-ref/source/v1/samples/keypairs/keypair-list-resp.json create mode 100644 api-ref/source/v1/samples/keypairs/keypair-post-req.json create mode 100644 api-ref/source/v1/samples/keypairs/keypair-post-resp.json create mode 100644 mogan/api/controllers/v1/keypairs.py create mode 100644 mogan/api/controllers/v1/schemas/keypairs.py create mode 100644 mogan/objects/keypair.py create mode 100644 mogan/tests/unit/objects/test_keypairs.py diff --git a/api-ref/source/v1/keypairs.inc b/api-ref/source/v1/keypairs.inc new file mode 100644 index 00000000..051fa99d --- /dev/null +++ b/api-ref/source/v1/keypairs.inc @@ -0,0 +1,155 @@ +.. -*- rst -*- + +===================== + Keypairs (keypairs) +===================== + +Generates, imports, and deletes SSH keys. + +List Keypairs +============= + +.. rest_method:: GET /keypairs + +Lists keypairs that are associated with the account. + +Normal response codes: 200 + +Error response codes: unauthorized(401), forbidden(403) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - user_id: keypair_user + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - created_at: created_at + - updated_at: created_at + - keypairs: keypairs + - user_id: keypair_userid_in + - name: keypair_name + - public_key: keypair_public_key + - fingerprint: keypair_fingerprint + - type: keypair_type + - links: links + +**Example List Keypairs: JSON response** + +.. literalinclude:: samples/keypairs/keypairs-list-resp.json + :language: javascript + +Create Or Import Keypair +======================== + +.. rest_method:: POST /keypairs + +Generates or imports a keypair. + +Normal response codes: 200, 201 + +.. note:: + + The success status code was changed from 200 to 201 in version 2.2 + +Error response codes: badRequest(400), unauthorized(401), forbidden(403), conflict(409) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - name: keypair_name + - public_key: keypair_public_key_in + - type: keypair_type_in + - user_id: keypair_userid_in + +**Example Create Or Import Keypair: JSON request** + +.. literalinclude:: samples/keypairs/keypairs-import-post-req.json + :language: javascript + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - created_at: created_at + - updated_at: created_at + - name: keypair_name + - public_key: keypair_public_key + - fingerprint: keypair_fingerprint + - user_id: keypair_userid + - private_key: keypair_private_key + - type: keypair_type + +**Example Create Or Import Keypair: JSON response** + +.. literalinclude:: samples/keypairs/keypairs-import-post-resp.json + :language: javascript + +Show Keypair Details +==================== + +.. rest_method:: GET /keypairs/{keypair_name} + +Shows details for a keypair that is associated with the account. + +Normal response codes: 200 + +Error response codes: unauthorized(401), forbidden(403), itemNotFound(404) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - keypair_name: keypair_name_path + - user_id: keypair_user + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - created_at: created_at + - updated_at: created_at + - fingerprint: keypair_fingerprint + - name: keypair_name + - public_key: keypair_public_key + - user_id: keypair_userid + - type: keypair_type + +**Example Show Keypair Details: JSON response** + +.. literalinclude:: samples/keypairs/keypairs-get-resp.json + :language: javascript + +Delete Keypair +============== + +.. rest_method:: DELETE /keypairs/{keypair_name} + +Deletes a keypair. + +Normal response codes: 204 + +Error response codes: unauthorized(401), forbidden(403), itemNotFound(404) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - keypair_name: keypair_name_path + - user_id: keypair_user + +Response +-------- + +There is no body content for the response of a successful DELETE query diff --git a/api-ref/source/v1/parameters.yaml b/api-ref/source/v1/parameters.yaml index 762e25cc..3bf00f53 100644 --- a/api-ref/source/v1/parameters.yaml +++ b/api-ref/source/v1/parameters.yaml @@ -219,6 +219,71 @@ instance_uuid: in: body required: true type: string +keypair_fingerprint: + in: body + required: true + type: string + description: | + The fingerprint for the keypair. +keypair_name: + in: body + required: true + type: string + description: | + A name for the keypair which will be used to reference it later. +keypair_private_key: + description: | + If you do not provide a public key on create, a new keypair will + be built for you, and the private key will be returned during the + initial create call. Make sure to save this, as there is no way to + get this private key again in the future. + in: body + required: false + type: string +keypair_public_key: + description: | + The keypair public key. + in: body + required: true + type: string +keypair_public_key_in: + description: | + The public ssh key to import. If you omit this value, a keypair is + generated for you. + in: body + required: false + type: string +keypair_type: + in: body + required: true + type: string + description: | + The type of the keypair. Allowed values are ``ssh`` or ``x509``. +keypair_type_in: + in: body + required: false + type: string + description: | + The type of the keypair. Allowed values are ``ssh`` or ``x509``. +keypair_userid: + in: body + required: true + type: string + description: | + The user_id for a keypair. +keypair_userid_in: + in: body + required: false + type: string + description: | + The user_id for a keypair. This allows administrative users to + upload keys for other users than themselves. +keypairs: + in: body + type: array + required: true + description: | + Array of Keypair objects launched_at: description: | The date and time when the instance was launched. The date and time diff --git a/api-ref/source/v1/samples/keypairs/keypair-get-resp.json b/api-ref/source/v1/samples/keypairs/keypair-get-resp.json new file mode 100644 index 00000000..9d152770 --- /dev/null +++ b/api-ref/source/v1/samples/keypairs/keypair-get-resp.json @@ -0,0 +1,19 @@ +{ + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCw10TR9arvKrQlleazdUe46FBDaixQa6DArb177nYzKTr/dLnHZo1xjLu9bHNgv8dBSs4fgnMbY1+qugCIfvq7DRnKH4jMGP6g+vf+xELa8BnA/wo+rY4Ei1IOoSOCo0pTMbMh0IZTv4a4GBCWsYBab97xGEytSaH4ysoWdayiOCuYsH7KVWvSZyZgV6SWbprYEJS4mFl7ZH6Yd4zSKMMR11xqYwcuRHf/+0kJV1cFci6mBSMLWha2UO7WS5hwOkSuiveQbGbbemQr1HRwF2xCupp3fB/3RSOjY9tXyODeHdKfWzOxL9T5KWuCuT3n7y5Dsweigya1gMNuo9X1ecXh Generated-by-Mogan", + "user_id": "4c1ea1d7a518420ca5fba507fdb38067", + "name": "test3", + "links": [ + { + "href": "http://10.229.40.107:6688/v1/keypairs/test3", + "rel": "self" + }, + { + "href": "http://10.229.40.107:6688/keypairs/test3", + "rel": "bookmark" + } + ], + "created_at": "2017-04-18T09:02:06+00:00", + "updated_at": null, + "fingerprint": "00:61:8b:1d:18:c6:73:8d:d0:02:75:8b:8e:b7:f7:ae", + "type": "ssh" +} diff --git a/api-ref/source/v1/samples/keypairs/keypair-import-post-req.json b/api-ref/source/v1/samples/keypairs/keypair-import-post-req.json new file mode 100644 index 00000000..e4c28217 --- /dev/null +++ b/api-ref/source/v1/samples/keypairs/keypair-import-post-req.json @@ -0,0 +1,4 @@ +{ + "name": "keypair-d20a3d59-9433-4b79-8726-20b431d89c78", + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated-by-Nova" +} diff --git a/api-ref/source/v1/samples/keypairs/keypair-import-post-resp.json b/api-ref/source/v1/samples/keypairs/keypair-import-post-resp.json new file mode 100644 index 00000000..aa34497e --- /dev/null +++ b/api-ref/source/v1/samples/keypairs/keypair-import-post-resp.json @@ -0,0 +1,19 @@ +{ + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated-by-Nova", + "user_id": "4c1ea1d7a518420ca5fba507fdb38067", + "name": "keypair-test", + "links": [ + { + "href": "http://10.229.40.107:6688/v1/keypairs/keypair-test", + "rel": "self" + }, + { + "href": "http://10.229.40.107:6688/keypairs/keypair-test", + "rel": "bookmark" + } + ], + "created_at": "2017-04-18T09:16:18.182631+00:00", + "updated_at": null, + "fingerprint": "1e:2c:9b:56:79:4b:45:77:f9:ca:7a:98:2c:b0:d5:3c", + "type": "ssh" +} diff --git a/api-ref/source/v1/samples/keypairs/keypair-list-resp.json b/api-ref/source/v1/samples/keypairs/keypair-list-resp.json new file mode 100644 index 00000000..4b85ad30 --- /dev/null +++ b/api-ref/source/v1/samples/keypairs/keypair-list-resp.json @@ -0,0 +1,23 @@ +{ + "keypairs": [ + { + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCw10TR9arvKrQlleazdUe46FBDaixQa6DArb177nYzKTr/dLnHZo1xjLu9bHNgv8dBSs4fgnMbY1+qugCIfvq7DRnKH4jMGP6g+vf+xELa8BnA/wo+rY4Ei1IOoSOCo0pTMbMh0IZTv4a4GBCWsYBab97xGEytSaH4ysoWdayiOCuYsH7KVWvSZyZgV6SWbprYEJS4mFl7ZH6Yd4zSKMMR11xqYwcuRHf/+0kJV1cFci6mBSMLWha2UO7WS5hwOkSuiveQbGbbemQr1HRwF2xCupp3fB/3RSOjY9tXyODeHdKfWzOxL9T5KWuCuT3n7y5Dsweigya1gMNuo9X1ecXh Generated-by-Mogan", + "user_id": "4c1ea1d7a518420ca5fba507fdb38067", + "name": "test3", + "links": [ + { + "href": "http://10.229.40.107:6688/v1/keypairs/test3", + "rel": "self" + }, + { + "href": "http://10.229.40.107:6688/keypairs/test3", + "rel": "bookmark" + } + ], + "created_at": "2017-04-18T09:02:06+00:00", + "updated_at": null, + "fingerprint": "00:61:8b:1d:18:c6:73:8d:d0:02:75:8b:8e:b7:f7:ae", + "type": "ssh" + } + ] +} diff --git a/api-ref/source/v1/samples/keypairs/keypair-post-req.json b/api-ref/source/v1/samples/keypairs/keypair-post-req.json new file mode 100644 index 00000000..f191a776 --- /dev/null +++ b/api-ref/source/v1/samples/keypairs/keypair-post-req.json @@ -0,0 +1,5 @@ +{ + "user_id": "15e4229548a64fa9b1ccb67423144252", + "type": "ssh", + "name": "test2" +} diff --git a/api-ref/source/v1/samples/keypairs/keypair-post-resp.json b/api-ref/source/v1/samples/keypairs/keypair-post-resp.json new file mode 100644 index 00000000..40d73ebe --- /dev/null +++ b/api-ref/source/v1/samples/keypairs/keypair-post-resp.json @@ -0,0 +1,20 @@ +{ + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDyCfP9RwNBG6BFvl8sWLRNRAC6QtTmYKflE7XPVAJZxceiouZ+5QbuDa8RPBorzfnlytAgwM/g9KyNvzX5NmH40cwP9h4uKoXZke3dK7UxCDv/ab+1UpONgbGOBN23gj6JRtlOCl7iRxSpgmKxiirbxKkpwFO1MI1dXYE7FHmrHsxy6FA4rKgM+U2aY8tPf80MHkMhO7P9nHf1YjLaHuRHmehKQgIAMUbb527PXW7JE3Q9qPjl5L4fpuP/dtx/hqfj3tEnQHY/8dyDtWLmi1U+7E7D8kbMh0PoMFfKUpwJ1e43MvvnOLuvIz8tTpOnN17pVpM1Yg5a7604Yb8LyOo/ Generated-by-Mogan", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA8gnz/UcDQRugRb5fLFi0TUQAukLU5mCn5RO1z1QCWcXHoqLm\nfuUG7g2vETwaK8355crQIMDP4PSsjb81+TZh+NHMD/YeLiqF2ZHt3Su1MQg7/2m/\ntVKTjYGxjgTdt4I+iUbZTgpe4kcUqYJisYoq28SpKcBTtTCNXV2BOxR5qx7McuhQ\nOKyoDPlNmmPLT3/NDB5DITuz/Zx39WIy2h7kR5noSkICADFG2+duz11uyRN0Paj4\n5eS+H6bj/3bcf4an497RJ0B2P/Hcg7Vi5otVPuxOw/JGzIdD6DBXylKcCdXuNzL7\n5zi7ryM/LU6Tpzde6VaTNWIOWu+tOGG/C8jqPwIDAQABAoIBAEgZEucFekCwvANK\nfAs3uS2y7gyNz+F4NUsVnfjOa4zWT2tw3vw5uOC8jsOxhZI63w/GZEz9Ym7+M1Bd\n/vPncTOvOvweMktKO2jeAV76oBSlAUpJ8+NNX8qtMXi+llUNpRc7VYvbpvv8dHkx\n77g3EiE46bMYKVc1yUZgjhhNNxjzlVyt3Hq5mo+LfrXKLxsr0WwH3pPivmEQ6USw\neyRGuqDQGdl4KbNg7UyeBMyKJ/YXwk1zzeAc/WSek/tRfEhrxnPwU/2fOKQl6pSS\nPE2XfNCCCH/yh6Wr8w/GO3Ob0S7i4zwQfptC+xfQJ/7P49OwtlHefGNwnpg04RWd\nWex8zEECgYEA/W9ek9IQ3HqA43UdXdebkEqzPEPcO+YLDjjHUzghyk/Hprao7W+M\nVvQMQ1PlUMMqeWayOQw5yQDETc94LTj6LV7enDkHmaAY+IHYXe+ScsLP+G23Hkw9\nn2IrxZI6zI+28+CmrAaCh+e3jLMrfEQbi3yOSxydHC2YZ7b53O7NXXECgYEA9H0O\nnK9VnyD8dpzbkVrmF3aGCE937201OKvtkaaHEaJJPRI4jN+a4iSAYVhJWSlLMbC5\nSFXyAy7vsTPUuhvFpEAKThUt5ULkXlKhmqtxE6+2xhbuqO5kpYzLK7oOdpyA4YqP\nNuMGbOq0ovAoxjHE24wxBzZrK9AV0ZOTRLppqq8CgYB50OdH7CfYojWDn05vRexr\nTcybQg8A55EW0+nTMV7kjLZthszp2708KnAeiJvn1vd6hQdTbnH0EJ9Ku1eLfSCb\nYEdmFe92Q0LdaCQk+ruM1+D5C1uCf6j7DEf33lLO8qFA1hGnDDX/tzw9r/1N7LrE\nsCkBJ47I9Y2VBJlTPaGOsQKBgQCAIvsBi7NoTzWCRPue1vE44tmkiWHmjmoSZamB\naLHpwBB6fY495wOZ+l9+pXLr1ASg6mpxSvooSPU+/ldDo0KWrym3esovGjvuY4hn\nM+tz0egNMf+rciY1zfC93imuaJ/zlVcyARJhCzHZI9164qK2HmejzBWnRMvqp1nL\n75dp6QKBgAmDdeINSwAOCGgB/byq8V4IcOrKxPLQUcwb5VcwQRH1CsyHAOmQv4I1\nv6gw5S7FeorVBwtyj+en6O98P27TCQUv6JxDD0c5Eh6KBjWzLNAxk7hwsHVCNT0k\nDhQ6pYuF8E7jaiHsk6aIcu1ADxIpCoPNqkmhLuL86XMAEuh07jV3\n-----END RSA PRIVATE KEY-----\n", + "user_id": "4c1ea1d7a518420ca5fba507fdb38067", + "name": "test16", + "links": [ + { + "href": "http://10.229.40.107:6688/v1/keypairs/test16", + "rel": "self" + }, + { + "href": "http://10.229.40.107:6688/keypairs/test16", + "rel": "bookmark" + } + ], + "created_at": "2017-04-19T03:01:34.398162+00:00", + "updated_at": null, + "fingerprint": "ab:11:7d:ef:6f:26:76:49:5c:cc:e9:be:6a:55:68:b3", + "type": "ssh" +} diff --git a/mogan/api/controllers/v1/__init__.py b/mogan/api/controllers/v1/__init__.py index d87f1787..98ccd211 100644 --- a/mogan/api/controllers/v1/__init__.py +++ b/mogan/api/controllers/v1/__init__.py @@ -28,6 +28,7 @@ from mogan.api.controllers import link from mogan.api.controllers.v1 import availability_zone from mogan.api.controllers.v1 import flavors from mogan.api.controllers.v1 import instances +from mogan.api.controllers.v1 import keypairs from mogan.api import expose @@ -46,6 +47,9 @@ class V1(base.APIBase): availability_zones = [link.Link] """Links to the availability zones resource""" + keypairs = [link.Link] + """Links to the keypairs resource""" + @staticmethod def convert(): v1 = V1() @@ -72,6 +76,14 @@ class V1(base.APIBase): 'availability_zones', '', bookmark=True) ] + v1.keypairs = [link.Link.make_link('self', + pecan.request.public_url, + 'keypairs', ''), + link.Link.make_link('bookmark', + pecan.request.public_url, + 'keypairs', '', + bookmark=True) + ] return v1 @@ -81,6 +93,7 @@ class Controller(rest.RestController): flavors = flavors.FlavorsController() instances = instances.InstanceController() availability_zones = availability_zone.AvailabilityZoneController() + keypairs = keypairs.KeyPairController() @expose.expose(V1) def get(self): diff --git a/mogan/api/controllers/v1/keypairs.py b/mogan/api/controllers/v1/keypairs.py new file mode 100644 index 00000000..4ad21a1e --- /dev/null +++ b/mogan/api/controllers/v1/keypairs.py @@ -0,0 +1,165 @@ +# Copyright 2017 Huawei Technologies Co.,LTD. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import pecan +from pecan import rest +from six.moves import http_client +import wsme +from wsme import types as wtypes + +from mogan.api.controllers import base +from mogan.api.controllers import link +from mogan.api.controllers.v1.schemas import keypairs as keypairs_schema +from mogan.api.controllers.v1 import types +from mogan.api import expose +from mogan.api import validation +from mogan import objects +from mogan.objects import keypair as keypair_obj + + +class KeyPair(base.APIBase): + """API representation of a keypair. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of + a keypair. + """ + user_id = types.uuid + """The user id of the keypair""" + + name = wtypes.text + """The name of the keypair""" + + fingerprint = wtypes.text + """The fingerprint of the keypair""" + + public_key = wtypes.text + """The public_key of the keypair""" + + private_key = wtypes.text + """The private_key of the keypair""" + + type = wtypes.Enum(str, 'ssh', 'x509') + """The type of the keypair""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link""" + + def __init__(self, **kwargs): + self.fields = [] + if 'private_key' in kwargs: + self.fields.append('private_key') + setattr(self, 'private_key', + kwargs.get('private_key', wtypes.Unset)) + for field in objects.KeyPair.fields: + # Skip fields we do not expose. + if not hasattr(self, field): + continue + self.fields.append(field) + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + @classmethod + def convert_with_links(cls, rpc_keypair): + if hasattr(rpc_keypair, 'private_key') and rpc_keypair.private_key: + keypair = KeyPair(private_key=rpc_keypair.private_key, + **rpc_keypair.as_dict()) + else: + keypair = KeyPair(**rpc_keypair.as_dict()) + url = pecan.request.public_url + keypair.links = [link.Link.make_link('self', url, + 'keypairs', + keypair.name), + link.Link.make_link('bookmark', url, + 'keypairs', + keypair.name, + bookmark=True) + ] + return keypair + + +class KeyPairCollection(base.APIBase): + """API representation of a collection of keypairs.""" + + keypairs = [KeyPair] + """A list containing Instance Type objects""" + + @staticmethod + def convert_with_links(keypairs, url=None, **kwargs): + collection = KeyPairCollection() + collection.keypairs = [KeyPair.convert_with_links(keypair) + for keypair in keypairs] + return collection + + +class KeyPairController(rest.RestController): + @expose.expose(KeyPair, body=types.jsontype, + status_code=http_client.CREATED) + def post(self, body): + """Generate a new keypair or import an existed keypair. + + :param body: the request body of keypair creation. + """ + validation.check_schema(body, keypairs_schema.create_keypair) + name = body['name'] + user_id = body.get('user_id') or pecan.request.context.user_id + key_type = body.get('type', keypair_obj.KEYPAIR_TYPE_SSH) + if 'public_key' in body: + keypair = pecan.request.engine_api.import_key_pair( + pecan.request.context, user_id, name, + body['public_key'], key_type) + else: + keypair, private_key = pecan.request.engine_api.create_key_pair( + pecan.request.context, user_id, name, key_type) + keypair.private_key = private_key + # Set the HTTP Location Header + pecan.response.location = link.build_url('keypairs', + keypair.name) + return KeyPair.convert_with_links(keypair) + + @expose.expose(None, wtypes.text, wtypes.text, + status_code=http_client.NO_CONTENT) + def delete(self, key_name, user_id=None): + """Delete a keypair. + + :param key_name: the name of keypair to be deleted. + """ + # TODO(liusheng): the input user_id should be only suport by admin + # as default, need to add policy check here. + user_id = user_id or pecan.request.context.user_id + pecan.request.engine_api.delete_key_pair( + pecan.request.context, user_id, key_name) + + @expose.expose(KeyPair, wtypes.text, wtypes.text) + def get_one(self, key_name, user_id=None): + """Query one keypair. + + :param key_name: the name of keypair to be queried. + """ + # TODO(liusheng): the input user_id should be only suport by admin + # as default, need to add policy check here. + user_id = user_id or pecan.request.context.user_id + keypair = pecan.request.engine_api.get_key_pair( + pecan.request.context, user_id, key_name) + return KeyPair.convert_with_links(keypair) + + @expose.expose(KeyPairCollection, wtypes.text) + def get_all(self, user_id=None): + """Query all keypairs of current user.""" + # TODO(liusheng): the input user_id should be only suport by admin + # as default, need to add policy check here. + user_id = user_id or pecan.request.context.user_id + keypairs = pecan.request.engine_api.get_key_pairs( + pecan.request.context, user_id) + return KeyPairCollection.convert_with_links(keypairs) diff --git a/mogan/api/controllers/v1/schemas/keypairs.py b/mogan/api/controllers/v1/schemas/keypairs.py new file mode 100644 index 00000000..88b0fd73 --- /dev/null +++ b/mogan/api/controllers/v1/schemas/keypairs.py @@ -0,0 +1,32 @@ +# Copyright 2017 Huawei Technologies Co.,LTD. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from mogan.api.validation import parameter_types + +create_keypair = { + "type": "object", + "properties": { + 'name': parameter_types.name, + 'user_id': {'type': 'string'}, + 'type': { + 'type': 'string', + 'enum': ['ssh', 'x509'] + }, + 'public_key': {'type': 'string'}, + }, + 'required': ['name'], + 'additionalProperties': False, +} diff --git a/mogan/common/exception.py b/mogan/common/exception.py index 95b326d6..23ddc817 100644 --- a/mogan/common/exception.py +++ b/mogan/common/exception.py @@ -400,4 +400,15 @@ class Base64Exception(MoganException): _msg_fmt = _("Invalid Base 64 data for file %(path)s") +class KeyPairExists(MoganException): + _msg_fmt = _("KeyPaire with key name %(key_name)s already exists.") + + +class KeypairNotFound(NotFound): + _msg_fmt = _("Keypair %(name)s not found for user %(user_id)s") + + +class InvalidKeypair(Invalid): + _msg_fmt = _("Keypair data is invalid: %(reason)s") + ObjectActionError = obj_exc.ObjectActionError diff --git a/mogan/common/utils.py b/mogan/common/utils.py index 2285e876..7c0bb643 100644 --- a/mogan/common/utils.py +++ b/mogan/common/utils.py @@ -15,6 +15,8 @@ """Utilities and helper functions.""" +import base64 +import binascii import contextlib import functools import inspect @@ -24,18 +26,23 @@ import shutil import tempfile import traceback +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography import x509 from oslo_concurrency import lockutils from oslo_concurrency import processutils from oslo_log import log as logging from oslo_utils import encodeutils +import paramiko import six from mogan.common import exception +from mogan.common.i18n import _ from mogan.common import states from mogan.conf import CONF from mogan import objects - LOG = logging.getLogger(__name__) synchronized = lockutils.synchronized_with_prefix('mogan-') @@ -283,3 +290,122 @@ def mkfs(fs, path, label=None, run_as_root=False): def trycmd(*args, **kwargs): """Convenience wrapper around oslo's trycmd() method.""" return processutils.trycmd(*args, **kwargs) + + +def check_string_length(value, name=None, min_length=0, max_length=None): + """Check the length of specified string + :param value: the value of the string + :param name: the name of the string + :param min_length: the min_length of the string + :param max_length: the max_length of the string + """ + if not isinstance(value, six.string_types): + if name is None: + msg = "The input is not a string or unicode" + else: + msg = "%s is not a string or unicode" % name + raise exception.Invalid(message=msg) + + if name is None: + name = value + + if len(value) < min_length: + msg = _("%(name)s has a minimum character requirement of " + "%(min_length)s.") % {'name': name, 'min_length': min_length} + raise exception.Invalid(message=msg) + + if max_length and len(value) > max_length: + msg = _("%(name)s has more than %(max_length)s " + "characters.") % {'name': name, 'max_length': max_length} + raise exception.Invalid(message=msg) + + +def _create_x509_openssl_config(conffile, upn): + content = ("distinguished_name = req_distinguished_name\n" + "[req_distinguished_name]\n" + "[v3_req_client]\n" + "extendedKeyUsage = clientAuth\n" + "subjectAltName = otherName:""1.3.6.1.4.1.311.20.2.3;UTF8:%s\n") + + with open(conffile, 'w') as file: + file.write(content % upn) + + +def generate_winrm_x509_cert(user_id, bits=2048): + """Generate a cert for passwordless auth for user in project.""" + subject = '/CN=%s' % user_id + upn = '%s@localhost' % user_id + + with tempdir() as tmpdir: + keyfile = os.path.abspath(os.path.join(tmpdir, 'temp.key')) + conffile = os.path.abspath(os.path.join(tmpdir, 'temp.conf')) + + _create_x509_openssl_config(conffile, upn) + + (certificate, _err) = execute( + 'openssl', 'req', '-x509', '-nodes', '-days', '3650', + '-config', conffile, '-newkey', 'rsa:%s' % bits, + '-outform', 'PEM', '-keyout', keyfile, '-subj', subject, + '-extensions', 'v3_req_client', + binary=True) + + (out, _err) = execute('openssl', 'pkcs12', '-export', + '-inkey', keyfile, '-password', 'pass:', + process_input=certificate, + binary=True) + + private_key = base64.b64encode(out) + fingerprint = generate_x509_fingerprint(certificate) + if six.PY3: + private_key = private_key.decode('ascii') + certificate = certificate.decode('utf-8') + + return (private_key, certificate, fingerprint) + + +def generate_fingerprint(public_key): + try: + pub_bytes = public_key.encode('utf-8') + # Test that the given public_key string is a proper ssh key. The + # returned object is unused since pyca/cryptography does not have a + # fingerprint method. + serialization.load_ssh_public_key( + pub_bytes, backends.default_backend()) + pub_data = base64.b64decode(public_key.split(' ')[1]) + digest = hashes.Hash(hashes.MD5(), backends.default_backend()) + digest.update(pub_data) + md5hash = digest.finalize() + raw_fp = binascii.hexlify(md5hash) + if six.PY3: + raw_fp = raw_fp.decode('ascii') + return ':'.join(a + b for a, b in zip(raw_fp[::2], raw_fp[1::2])) + except Exception: + raise exception.InvalidKeypair( + reason=_('failed to generate fingerprint')) + + +def generate_x509_fingerprint(pem_key): + try: + if isinstance(pem_key, six.text_type): + pem_key = pem_key.encode('utf-8') + cert = x509.load_pem_x509_certificate( + pem_key, backends.default_backend()) + raw_fp = binascii.hexlify(cert.fingerprint(hashes.SHA1())) + if six.PY3: + raw_fp = raw_fp.decode('ascii') + return ':'.join(a + b for a, b in zip(raw_fp[::2], raw_fp[1::2])) + except (ValueError, TypeError, binascii.Error) as ex: + raise exception.InvalidKeypair( + reason=_('failed to generate X509 fingerprint. ' + 'Error message: %s') % ex) + + +def generate_key_pair(bits=2048): + key = paramiko.RSAKey.generate(bits) + keyout = six.StringIO() + key.write_private_key(keyout) + private_key = keyout.getvalue() + public_key = '%s %s Generated-by-Mogan' % ( + key.get_name(), key.get_base64()) + fingerprint = generate_fingerprint(public_key) + return (private_key, public_key, fingerprint) diff --git a/mogan/db/api.py b/mogan/db/api.py index b77a2d38..e7adf3ad 100644 --- a/mogan/db/api.py +++ b/mogan/db/api.py @@ -248,3 +248,28 @@ class Connection(object): @abc.abstractmethod def reservation_expire(self, context): """expire all reservations which has been expired""" + + @abc.abstractmethod + def key_pair_create(self, context, values): + """Create a key_pair from the values dictionary.""" + return IMPL.key_pair_create(context, values) + + @abc.abstractmethod + def key_pair_destroy(self, context, user_id, name): + """Destroy the key_pair or raise if it does not exist.""" + return IMPL.key_pair_destroy(context, user_id, name) + + @abc.abstractmethod + def key_pair_get(self, context, user_id, name): + """Get a key_pair or raise if it does not exist.""" + return IMPL.key_pair_get(context, user_id, name) + + @abc.abstractmethod + def key_pair_get_all_by_user(self, context, user_id): + """Get all key_pairs by user.""" + return IMPL.key_pair_get_all_by_user(context, user_id) + + @abc.abstractmethod + def key_pair_count_by_user(self, context, user_id): + """Count number of key pairs for the given user ID.""" + return IMPL.key_pair_count_by_user(context, user_id) diff --git a/mogan/db/sqlalchemy/alembic/versions/91941bf1ebc9_initial_migration.py b/mogan/db/sqlalchemy/alembic/versions/91941bf1ebc9_initial_migration.py index 45485005..56faf4ef 100644 --- a/mogan/db/sqlalchemy/alembic/versions/91941bf1ebc9_initial_migration.py +++ b/mogan/db/sqlalchemy/alembic/versions/91941bf1ebc9_initial_migration.py @@ -224,3 +224,19 @@ def upgrade(): mysql_ENGINE='InnoDB', mysql_DEFAULT_CHARSET='UTF8' ) + op.create_table( + 'key_pairs', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), primary_key=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('user_id', sa.String(length=255), nullable=True), + sa.Column('fingerprint', sa.String(255)), + sa.Column('public_key', sa.Text()), + sa.Column('type', sa.Enum('ssh', 'x509'), nullable=False, + default='ssh'), + sa.UniqueConstraint('user_id', 'name', + name="uniq_key_pairs0user_id0name"), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) diff --git a/mogan/db/sqlalchemy/api.py b/mogan/db/sqlalchemy/api.py index ddd382b0..97f9ea99 100644 --- a/mogan/db/sqlalchemy/api.py +++ b/mogan/db/sqlalchemy/api.py @@ -836,6 +836,38 @@ class Connection(api.Connection): uuid = reservation.uuid raise exception.ReservationNotFound(uuid=uuid) + def key_pair_create(self, context, values): + key_pair_ref = models.KeyPair() + key_pair_ref.update(values) + with _session_for_write() as session: + try: + session.add(key_pair_ref) + session.flush() + except db_exc.DBDuplicateEntry: + raise exception.KeyPairExists(key_name=values['name']) + return key_pair_ref + + def key_pair_destroy(self, context, user_id, name): + result = model_query(context, models.KeyPair).filter_by( + user_id=user_id).filter_by(name=name).delete() + if not result: + raise exception.KeypairNotFound(user_id=user_id, name=name) + + def key_pair_get(self, context, user_id, name): + result = model_query(context, models.KeyPair).filter_by( + user_id=user_id).filter_by(name=name).first() + if not result: + raise exception.KeypairNotFound(user_id=user_id, name=name) + return result + + def key_pair_get_all_by_user(self, context, user_id): + query = model_query(context, models.KeyPair).filter_by(user_id=user_id) + return query.all() + + def key_pair_count_by_user(self, context, user_id): + return model_query(context, models.KeyPair).filter_by( + user_id=user_id).count() + def _type_get_id_from_type_query(context, type_id): return model_query(context, models.InstanceTypes). \ diff --git a/mogan/db/sqlalchemy/models.py b/mogan/db/sqlalchemy/models.py index 4fab496a..954a4cd0 100644 --- a/mogan/db/sqlalchemy/models.py +++ b/mogan/db/sqlalchemy/models.py @@ -312,3 +312,22 @@ class Reservation(Base): quota = orm.relationship( "Quota", foreign_keys=allocated_id, primaryjoin='Reservation.allocated_id == Quota.id') + + +class KeyPair(Base): + """Represents a public key pair for ssh / WinRM.""" + __tablename__ = 'key_pairs' + __table_args__ = ( + schema.UniqueConstraint("user_id", "name", + name="uniq_key_pairs0user_id0name"), + ) + id = Column(Integer, primary_key=True, nullable=False) + + name = Column(String(255), nullable=False) + + user_id = Column(String(255), nullable=False) + + fingerprint = Column(String(255)) + public_key = Column(Text()) + type = Column(Enum('ssh', 'x509', name='keypair_types'), + nullable=False, server_default='ssh') diff --git a/mogan/engine/api.py b/mogan/engine/api.py index 75fe6962..decfa8c8 100644 --- a/mogan/engine/api.py +++ b/mogan/engine/api.py @@ -14,6 +14,7 @@ # under the License. """Handles all requests relating to compute resources""" +import string import base64 import binascii @@ -33,6 +34,7 @@ from mogan.engine import rpcapi from mogan import image from mogan import network from mogan import objects +from mogan.objects import keypair as keypair_obj from mogan.objects import quota LOG = log.getLogger(__name__) @@ -422,3 +424,79 @@ class API(object): access_url=connect_info['access_url']) return {'url': connect_info['access_url']} + + def _validate_new_key_pair(self, context, user_id, key_name, key_type): + safe_chars = "_- " + string.digits + string.ascii_letters + clean_value = "".join(x for x in key_name if x in safe_chars) + if clean_value != key_name: + raise exception.InvalidKeypair( + reason="Keypair name contains unsafe characters") + + try: + utils.check_string_length(key_name, min_length=1, max_length=255) + except exception.Invalid: + raise exception.InvalidKeypair( + reason='Keypair name must be string and between ' + '1 and 255 characters long') + + # TODO(liusheng) add quota check + # count = objects.Quotas.count(context, 'key_pairs', user_id) + # + # try: + # objects.Quotas.limit_check(context, key_pairs=count + 1) + # except exception.OverQuota: + # raise exception.KeypairLimitExceeded() + + def import_key_pair(self, context, user_id, key_name, public_key, + key_type=keypair_obj.KEYPAIR_TYPE_SSH): + """Import a key pair using an existing public key.""" + self._validate_new_key_pair(context, user_id, key_name, key_type) + fingerprint = self._generate_fingerprint(public_key, key_type) + + keypair = objects.KeyPair(context) + keypair.user_id = user_id + keypair.name = key_name + keypair.type = key_type + keypair.fingerprint = fingerprint + keypair.public_key = public_key + keypair.create() + return keypair + + def create_key_pair(self, context, user_id, key_name, + key_type=keypair_obj.KEYPAIR_TYPE_SSH): + """Create a new key pair.""" + self._validate_new_key_pair(context, user_id, key_name, key_type) + private_key, public_key, fingerprint = self._generate_key_pair( + user_id, key_type) + keypair = objects.KeyPair(context) + keypair.user_id = user_id + keypair.name = key_name + keypair.type = key_type + keypair.fingerprint = fingerprint + keypair.public_key = public_key + keypair.create() + return keypair, private_key + + def _generate_fingerprint(self, public_key, key_type): + if key_type == keypair_obj.KEYPAIR_TYPE_SSH: + return utils.generate_fingerprint(public_key) + elif key_type == keypair_obj.KEYPAIR_TYPE_X509: + return utils.generate_x509_fingerprint(public_key) + + def _generate_key_pair(self, user_id, key_type): + if key_type == keypair_obj.KEYPAIR_TYPE_SSH: + return utils.generate_key_pair() + elif key_type == keypair_obj.KEYPAIR_TYPE_X509: + return utils.generate_winrm_x509_cert(user_id) + + def delete_key_pair(self, context, user_id, key_name): + """Delete a keypair by name.""" + objects.KeyPair.destroy_by_name(context, user_id, key_name) + + def get_key_pairs(self, context, user_id): + """List key pairs.""" + return objects.KeyPairList.get_by_user(context, user_id) + + def get_key_pair(self, context, user_id, key_name): + """Get a keypair by name.""" + return objects.KeyPair.get_by_name(context, user_id, key_name) diff --git a/mogan/objects/__init__.py b/mogan/objects/__init__.py index 184fc0dc..4b1e613a 100644 --- a/mogan/objects/__init__.py +++ b/mogan/objects/__init__.py @@ -32,3 +32,4 @@ def register_all(): __import__('mogan.objects.compute_node') __import__('mogan.objects.compute_port') __import__('mogan.objects.compute_disk') + __import__('mogan.objects.keypair') diff --git a/mogan/objects/keypair.py b/mogan/objects/keypair.py new file mode 100644 index 00000000..cd80eb5a --- /dev/null +++ b/mogan/objects/keypair.py @@ -0,0 +1,103 @@ +# Copyright 2017 Huawei Technologies Co.,LTD. +# +# 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_versionedobjects import base as object_base + +from mogan.common import exception +from mogan.db import api as dbapi +from mogan import objects +from mogan.objects import base +from mogan.objects import fields + +KEYPAIR_TYPE_SSH = 'ssh' +KEYPAIR_TYPE_X509 = 'x509' + + +@base.MoganObjectRegistry.register +class KeyPair(base.MoganObject): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = dbapi.get_instance() + + fields = { + 'id': fields.IntegerField(), + 'name': fields.StringField(nullable=False), + 'user_id': fields.StringField(nullable=True), + 'fingerprint': fields.StringField(nullable=True), + 'public_key': fields.StringField(nullable=True), + 'type': fields.StringField(nullable=False), + } + + @staticmethod + def _from_db_object(context, keypair, db_keypair): + ignore = {'deleted': False, + 'deleted_at': None} + for key in keypair.fields: + if key in ignore and not hasattr(db_keypair, key): + setattr(keypair, key, ignore[key]) + else: + setattr(keypair, key, db_keypair[key]) + keypair._context = context + keypair.obj_reset_changes() + return keypair + + @classmethod + def get_by_name(cls, context, user_id, name): + db_keypair = cls.dbapi.key_pair_get(context, user_id, name) + return cls._from_db_object(context, cls(), db_keypair) + + @classmethod + def destroy_by_name(cls, context, user_id, name): + cls.dbapi.key_pair_destroy(context, user_id, name) + + def create(self): + if self.obj_attr_is_set('id'): + raise exception.ObjectActionError(action='create', + reason='already created') + try: + self.dbapi.key_pair_get(self._context, self.user_id, self.name) + raise exception.KeyPairExists(key_name=self.name) + except exception.KeypairNotFound: + pass + updates = self.obj_get_changes() + db_keypair = self.dbapi.key_pair_create(self._context, updates) + self._from_db_object(self._context, self, db_keypair) + + def destroy(self): + self.dbapi.key_pair_destroy(self._context, self.user_id, self.name) + + +@base.MoganObjectRegistry.register +class KeyPairList(object_base.ObjectListBase, base.MoganObject): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = dbapi.get_instance() + + fields = { + 'objects': fields.ListOfObjectsField('KeyPair'), + } + + @classmethod + def get_count_from_db(cls, context, user_id): + return cls.dbapi.key_pair_count_by_user(context, user_id) + + @classmethod + def get_by_user(cls, context, user_id): + db_keypairs = cls.dbapi.key_pair_get_all_by_user(context, user_id) + + return object_base.obj_make_list( + context, cls(context), objects.KeyPair, db_keypairs) diff --git a/mogan/tests/unit/objects/test_keypairs.py b/mogan/tests/unit/objects/test_keypairs.py new file mode 100644 index 00000000..ec1d618b --- /dev/null +++ b/mogan/tests/unit/objects/test_keypairs.py @@ -0,0 +1,83 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import copy + +import mock +from oslo_context import context + +from mogan.common import exception +from mogan import objects +from mogan.tests.unit.db import base + + +class TestKeyPairObject(base.DbTestCase): + def setUp(self): + super(TestKeyPairObject, self).setUp() + self.ctxt = context.get_admin_context() + self.fake_keypair = { + "id": 1, + "public_key": "fake-publick-key", + "user_id": "e78b60069fc9467e97fb4b74de9cadc1", + "name": "test_key", + "fingerprint": "f1:83:34:02:f9:63:79:d4:bd:2a:1d:50:16:61:1b:cc", + "type": "ssh", + "created_at": "2017-04-18T09:16:18.182631+00:00", + "updated_at": None + } + + def test_get(self): + with mock.patch.object(self.dbapi, 'key_pair_get', + autospec=True) as mock_keypair_get: + mock_keypair_get.return_value = self.fake_keypair + + keypair = objects.KeyPair.get_by_name( + self.context, self.fake_keypair['user_id'], + self.fake_keypair['name']) + + mock_keypair_get.assert_called_once_with( + self.context, self.fake_keypair['user_id'], + self.fake_keypair['name']) + self.assertEqual(self.context, keypair._context) + self.assertEqual(self.fake_keypair['name'], keypair.name) + self.assertEqual(self.fake_keypair['user_id'], keypair.user_id) + + def test_create(self): + with mock.patch.object(self.dbapi, 'key_pair_get', + autospec=True) as mock_keypair_get: + with mock.patch.object(self.dbapi, 'key_pair_create', + autospec=True) as mock_keypair_create: + mock_keypair_get.side_effect = exception.KeypairNotFound( + user_id=self.fake_keypair['user_id'], + name=self.fake_keypair['name']) + mock_keypair_create.return_value = self.fake_keypair + create_params = copy.copy(self.fake_keypair) + create_params.pop('id') + keypair = objects.KeyPair(self.context, + **create_params) + values = keypair.obj_get_changes() + keypair.create() + mock_keypair_create.assert_called_once_with( + self.context, values) + self.assertEqual(self.fake_keypair['name'], keypair.name) + self.assertEqual(self.fake_keypair['user_id'], keypair.user_id) + + def test_destroy(self): + with mock.patch.object(self.dbapi, 'key_pair_destroy', + autospec=True) as mock_keypair_destroy: + mock_keypair_destroy.return_value = self.fake_keypair + keypair = objects.KeyPair(self.context, + **self.fake_keypair) + keypair.destroy() + mock_keypair_destroy.assert_called_once_with( + self.context, self.fake_keypair['user_id'], + self.fake_keypair['name']) diff --git a/mogan/tests/unit/objects/test_objects.py b/mogan/tests/unit/objects/test_objects.py index d1246049..35d66815 100644 --- a/mogan/tests/unit/objects/test_objects.py +++ b/mogan/tests/unit/objects/test_objects.py @@ -396,6 +396,8 @@ expected_object_fingerprints = { 'InstanceNic': '1.0-78744332fe105f9c1796dc5295713d9f', 'InstanceNics': '1.0-33a2e1bb91ad4082f9f63429b77c1244', 'Quota': '1.0-c8caa082f4d726cb63fdc5943f7cd186', + 'KeyPair': '1.0-c6820166e307676c5900f7801831b84c', + 'KeyPairList': '1.0-33a2e1bb91ad4082f9f63429b77c1244' } diff --git a/requirements.txt b/requirements.txt index b1ef02ff..70eb8ae5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ SQLAlchemy!=1.1.5,!=1.1.6,!=1.1.7,!=1.1.8,>=1.0.10 # MIT alembic>=0.8.10 # MIT eventlet!=0.18.3,>=0.18.2 # MIT WebOb>=1.7.1 # MIT +cryptography>=1.6 # BSD/Apache-2.0 python-ironicclient>=1.11.0 # Apache-2.0 python-neutronclient>=5.1.0 # Apache-2.0 python-glanceclient>=2.5.0 # Apache-2.0