Merge lp:~jk0/nova/backup_with_rotation into lp:~hudson-openstack/nova/trunk

Proposed by Josh Kearney
Status: Merged
Approved by: Brian Lamar
Approved revision: 1228
Merged at revision: 1226
Proposed branch: lp:~jk0/nova/backup_with_rotation
Merge into: lp:~hudson-openstack/nova/trunk
Diff against target: 573 lines (+298/-44)
7 files modified
nova/api/openstack/images.py (+41/-5)
nova/compute/api.py (+44/-3)
nova/compute/manager.py (+73/-2)
nova/db/sqlalchemy/migrate_repo/versions/027_add_provider_firewall_rules.py (+1/-1)
nova/exception.py (+8/-0)
nova/tests/api/openstack/fakes.py (+10/-0)
nova/tests/api/openstack/test_images.py (+121/-33)
To merge this branch: bzr merge lp:~jk0/nova/backup_with_rotation
Reviewer Review Type Date Requested Status
Brian Lamar (community) Approve
Brian Waldon (community) Approve
Review via email: mp+65835@code.launchpad.net

Description of the change

Implement backup with rotation and expose this functionality in the OS API.

To post a comment you must log in.
Revision history for this message
Vish Ishaya (vishvananda) wrote :

All this looks good. Just unsure if this is in the api spec or should be an extension?

Revision history for this message
Brian Lamar (blamar) wrote :

Vish/Josh,

I feel like I say this a lot, but this logic doesn't belong in nova/compute/*. If we want the OpenStack API to support backing up images with sweet rotation logic, I would argue it should be implemented as a completely different service.

OpenStack API provides the ability to:
-Backup images
-Query images by creation time/modification time
-Delete a single image or multiple images

Other Service uses these API calls to implement:
-Scheduled deletion of images
-Migration of old images to tape-backup/long term storage/order them on CD-ROMs/whatever

However!

I could be convinced that this logic should go in to glance...because at the very least this isn't a compute task, but an image-store task.

review: Needs Fixing
Revision history for this message
Brian Waldon (bcwaldon) wrote :

I think this is a great idea, and a feature that we could really benefit from. However, I'm hesitant to say this belongs in Nova. This feels like a feature in a future "scheduling service" that can execute things at certain times.

If the decision is made to keep this in Nova, I would like to see the OS API functionality implemented as an extension. This isn't part of the v1.1 API specification.

review: Needs Information
Revision history for this message
Brian Waldon (bcwaldon) wrote :

After giving it some thought, I'm much more okay with this implementation as a temporary solution. I also don't have a problem with the API change if it is considered part of the admin api. However, being part of the admin api, does that mean that a deployer will have to set up each api node with the admin api on?

Fix up these few things, and it's good to go:

20-25: Can you find a more common place to put this? I feel like we could use this logic in a lot of places.

186, 193: instantiate these classes

214: you might want to specify a limit kwarg here. It defaults to 25

220: There is a branch in MP right now in glance that allows db-level sorting: lp:~rackspace-titan/glance/api-results-ordering

243-245: I don't think this change is necessary

review: Needs Fixing
Revision history for this message
Josh Kearney (jk0) wrote :

Thanks for the feedback (changes coming shortly).

> 243-245: I don't think this change is necessary

The pep8 version in the current pip-requires picked this up.

lp:~jk0/nova/backup_with_rotation updated
1220. By Josh Kearney

Review feedback.

1221. By Josh Kearney

Review feedback.

1222. By Josh Kearney

Whoops.

1223. By Josh Kearney

OOPS

Revision history for this message
Brian Lamar (blamar) wrote :

Lines 24, 53, 63: Can you add a explanation=<message> keyword argument to the bad request? It'll help know what exactly went wrong for the user.

review: Needs Fixing
lp:~jk0/nova/backup_with_rotation updated
1224. By Josh Kearney

Review feedback.

Revision history for this message
Brian Waldon (bcwaldon) wrote :

Looks like these couple of things still need to be addressed:

20-25: Can you find a more common place to put this? I feel like we could use this logic in a lot of places.

236: The sorting functionality went in today. You can add sort_key and sort_dir keyword params to handle your sorting

Revision history for this message
Rick Harris (rconradharris) wrote :

> 20-25: Can you find a more common place to put this? I feel like we could use this logic in a lot of places.

As it's written, this was custom-tailored for this method. We /could/ re-work it to be more generic and use it in other places; the drawback there is that it ends up somewhat defeating the original purpose, DRYing up the code we're modifying.

If we do want to put a version of this code in a separate place, I'd vote that we do that as we're refactoring some of the code that would use it.

Revision history for this message
Brian Waldon (bcwaldon) wrote :

> > 20-25: Can you find a more common place to put this? I feel like we could
> use this logic in a lot of places.
>
> As it's written, this was custom-tailored for this method. We /could/ re-work
> it to be more generic and use it in other places; the drawback there is that
> it ends up somewhat defeating the original purpose, DRYing up the code we're
> modifying.
>
> If we do want to put a version of this code in a separate place, I'd vote that
> we do that as we're refactoring some of the code that would use it.

Sounds reasonable.

lp:~jk0/nova/backup_with_rotation updated
1225. By Josh Kearney

Let glance handle sorting.

1226. By Josh Kearney

Merged trunk

1227. By Josh Kearney

Merged trunk

Revision history for this message
Brian Waldon (bcwaldon) wrote :

Josh, I think it might be a good idea to explicitly add sort_key/sort_dir params to protect from future changes. I would like the peace of mind to know we won't get screwed here if the default sort key/direction change. Is that reasonable?

lp:~jk0/nova/backup_with_rotation updated
1228. By Josh Kearney

Review feedback

Revision history for this message
Josh Kearney (jk0) wrote :

> Josh, I think it might be a good idea to explicitly add sort_key/sort_dir
> params to protect from future changes. I would like the peace of mind to know
> we won't get screwed here if the default sort key/direction change. Is that
> reasonable?

Yep. All set.

Revision history for this message
Brian Waldon (bcwaldon) wrote :

> > Josh, I think it might be a good idea to explicitly add sort_key/sort_dir
> > params to protect from future changes. I would like the peace of mind to
> know
> > we won't get screwed here if the default sort key/direction change. Is that
> > reasonable?
>
> Yep. All set.

Awesome. Great work, guys.

review: Approve
Revision history for this message
Brian Lamar (blamar) wrote :

I really want to help you guys get this in, but what are your thoughts on making this inline with the other admin API functions? Currently nova/api/openstack/__init__.py loads routes if FLAGS.allow_admin_api is set, could we maybe add:

server_members['backup'] = 'POST'

and then have the backup logic go in the servers controller? My main point here is that we're backing up a server...not an image right? It seems odd to say "backup this image" because an image is something you start a server from. Also, it seems more 'secure' to not even load the routes when allow_admin_api flag isn't set, like the existing admin API methods.

Revision history for this message
Josh Kearney (jk0) wrote :

> I really want to help you guys get this in, but what are your thoughts on
> making this inline with the other admin API functions? Currently
> nova/api/openstack/__init__.py loads routes if FLAGS.allow_admin_api is set,
> could we maybe add:
>
> server_members['backup'] = 'POST'
>
> and then have the backup logic go in the servers controller? My main point
> here is that we're backing up a server...not an image right? It seems odd to
> say "backup this image" because an image is something you start a server from.
> Also, it seems more 'secure' to not even load the routes when allow_admin_api
> flag isn't set, like the existing admin API methods.

From a Glance perspective everything is considered an image (base images, snapshots, backups, etc), so we felt most comfortable including this portion of the code in images. Same idea in python-novaclient. For example:

Snapshot: nova image-create <instance ID> <snapshot name>

Backup: nova image-create <instance ID> <backup name> --image-type backup --backup-type <daily|weeky> --rotation <arbitrary number>

So for the most part, the only differences between these two are the image_type, backup_type and rotation properties in glance.

I do like what you're suggesting, but would this refactor be better suited in its own branch / BP?

Revision history for this message
Brian Lamar (blamar) wrote :

Yeah, can you maybe make a blueprint to look at this as a whole in Nova? Nova should be instance-focused and Glance should be image-focused. It's been stated multiple times that /images in Nova is going away so we need to have a blueprint to show us the way.

This looks good and passes all of the normal test suites.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'nova/api/openstack/images.py'
2--- nova/api/openstack/images.py 2011-06-24 12:01:51 +0000
3+++ nova/api/openstack/images.py 2011-06-28 23:51:36 +0000
4@@ -90,31 +90,67 @@
5 return webob.exc.HTTPNoContent()
6
7 def create(self, req, body):
8- """Snapshot a server instance and save the image.
9+ """Snapshot or backup a server instance and save the image.
10+
11+ Images now have an `image_type` associated with them, which can be
12+ 'snapshot' or the backup type, like 'daily' or 'weekly'.
13+
14+ If the image_type is backup-like, then the rotation factor can be
15+ included and that will cause the oldest backups that exceed the
16+ rotation factor to be deleted.
17
18 :param req: `wsgi.Request` object
19 """
20+ def get_param(param):
21+ try:
22+ return body["image"][param]
23+ except KeyError:
24+ raise webob.exc.HTTPBadRequest(explanation="Missing required "
25+ "param: %s" % param)
26+
27 context = req.environ['nova.context']
28 content_type = req.get_content_type()
29
30 if not body:
31 raise webob.exc.HTTPBadRequest()
32
33+ image_type = body["image"].get("image_type", "snapshot")
34+
35 try:
36 server_id = self._server_id_from_req(req, body)
37- image_name = body["image"]["name"]
38 except KeyError:
39 raise webob.exc.HTTPBadRequest()
40
41+ image_name = get_param("name")
42 props = self._get_extra_properties(req, body)
43
44- image = self._compute_service.snapshot(context, server_id,
45- image_name, props)
46+ if image_type == "snapshot":
47+ image = self._compute_service.snapshot(
48+ context, server_id, image_name,
49+ extra_properties=props)
50+ elif image_type == "backup":
51+ # NOTE(sirp): Unlike snapshot, backup is not a customer facing
52+ # API call; rather, it's used by the internal backup scheduler
53+ if not FLAGS.allow_admin_api:
54+ raise webob.exc.HTTPBadRequest(
55+ explanation="Admin API Required")
56+
57+ backup_type = get_param("backup_type")
58+ rotation = int(get_param("rotation"))
59+
60+ image = self._compute_service.backup(
61+ context, server_id, image_name,
62+ backup_type, rotation, extra_properties=props)
63+ else:
64+ LOG.error(_("Invalid image_type '%s' passed") % image_type)
65+ raise webob.exc.HTTPBadRequest(explanation="Invalue image_type: "
66+ "%s" % image_type)
67+
68 return dict(image=self.get_builder(req).build(image, detail=True))
69
70 def get_builder(self, request):
71 """Indicates that you must use a Controller subclass."""
72- raise NotImplementedError
73+ raise NotImplementedError()
74
75 def _server_id_from_req(self, req, data):
76 raise NotImplementedError()
77
78=== modified file 'nova/compute/api.py'
79--- nova/compute/api.py 2011-06-24 12:07:33 +0000
80+++ nova/compute/api.py 2011-06-28 23:51:36 +0000
81@@ -711,19 +711,60 @@
82 raise exception.Error(_("Unable to find host for Instance %s")
83 % instance_id)
84
85+ def backup(self, context, instance_id, name, backup_type, rotation,
86+ extra_properties=None):
87+ """Backup the given instance
88+
89+ :param instance_id: nova.db.sqlalchemy.models.Instance.Id
90+ :param name: name of the backup or snapshot
91+ name = backup_type # daily backups are called 'daily'
92+ :param rotation: int representing how many backups to keep around;
93+ None if rotation shouldn't be used (as in the case of snapshots)
94+ :param extra_properties: dict of extra image properties to include
95+ """
96+ recv_meta = self._create_image(context, instance_id, name, 'backup',
97+ backup_type=backup_type, rotation=rotation,
98+ extra_properties=extra_properties)
99+ return recv_meta
100+
101 def snapshot(self, context, instance_id, name, extra_properties=None):
102 """Snapshot the given instance.
103
104+ :param instance_id: nova.db.sqlalchemy.models.Instance.Id
105+ :param name: name of the backup or snapshot
106+ :param extra_properties: dict of extra image properties to include
107+
108 :returns: A dict containing image metadata
109 """
110- properties = {'instance_id': str(instance_id),
111+ return self._create_image(context, instance_id, name, 'snapshot',
112+ extra_properties=extra_properties)
113+
114+ def _create_image(self, context, instance_id, name, image_type,
115+ backup_type=None, rotation=None, extra_properties=None):
116+ """Create snapshot or backup for an instance on this host.
117+
118+ :param context: security context
119+ :param instance_id: nova.db.sqlalchemy.models.Instance.Id
120+ :param name: string for name of the snapshot
121+ :param image_type: snapshot | backup
122+ :param backup_type: daily | weekly
123+ :param rotation: int representing how many backups to keep around;
124+ None if rotation shouldn't be used (as in the case of snapshots)
125+ :param extra_properties: dict of extra image properties to include
126+
127+ """
128+ instance = db.api.instance_get(context, instance_id)
129+ properties = {'instance_uuid': instance['uuid'],
130 'user_id': str(context.user_id),
131- 'image_state': 'creating'}
132+ 'image_state': 'creating',
133+ 'image_type': image_type,
134+ 'backup_type': backup_type}
135 properties.update(extra_properties or {})
136 sent_meta = {'name': name, 'is_public': False,
137 'status': 'creating', 'properties': properties}
138 recv_meta = self.image_service.create(context, sent_meta)
139- params = {'image_id': recv_meta['id']}
140+ params = {'image_id': recv_meta['id'], 'image_type': image_type,
141+ 'backup_type': backup_type, 'rotation': rotation}
142 self._cast_compute_message('snapshot_instance', context, instance_id,
143 params=params)
144 return recv_meta
145
146=== modified file 'nova/compute/manager.py'
147--- nova/compute/manager.py 2011-06-28 20:37:05 +0000
148+++ nova/compute/manager.py 2011-06-28 23:51:36 +0000
149@@ -46,6 +46,7 @@
150
151 from nova import exception
152 from nova import flags
153+import nova.image
154 from nova import log as logging
155 from nova import manager
156 from nova import network
157@@ -506,8 +507,19 @@
158 self._update_state(context, instance_id)
159
160 @exception.wrap_exception
161- def snapshot_instance(self, context, instance_id, image_id):
162- """Snapshot an instance on this host."""
163+ def snapshot_instance(self, context, instance_id, image_id,
164+ image_type='snapshot', backup_type=None,
165+ rotation=None):
166+ """Snapshot an instance on this host.
167+
168+ :param context: security context
169+ :param instance_id: nova.db.sqlalchemy.models.Instance.Id
170+ :param image_id: glance.db.sqlalchemy.models.Image.Id
171+ :param image_type: snapshot | backup
172+ :param backup_type: daily | weekly
173+ :param rotation: int representing how many backups to keep around;
174+ None if rotation shouldn't be used (as in the case of snapshots)
175+ """
176 context = context.elevated()
177 instance_ref = self.db.instance_get(context, instance_id)
178
179@@ -527,6 +539,65 @@
180
181 self.driver.snapshot(instance_ref, image_id)
182
183+ if image_type == 'snapshot':
184+ if rotation:
185+ raise exception.ImageRotationNotAllowed()
186+ elif image_type == 'backup':
187+ if rotation:
188+ instance_uuid = instance_ref['uuid']
189+ self.rotate_backups(context, instance_uuid, backup_type,
190+ rotation)
191+ else:
192+ raise exception.RotationRequiredForBackup()
193+ else:
194+ raise Exception(_('Image type not recognized %s') % image_type)
195+
196+ def rotate_backups(self, context, instance_uuid, backup_type, rotation):
197+ """Delete excess backups associated to an instance.
198+
199+ Instances are allowed a fixed number of backups (the rotation number);
200+ this method deletes the oldest backups that exceed the rotation
201+ threshold.
202+
203+ :param context: security context
204+ :param instance_uuid: string representing uuid of instance
205+ :param backup_type: daily | weekly
206+ :param rotation: int representing how many backups to keep around;
207+ None if rotation shouldn't be used (as in the case of snapshots)
208+ """
209+ # NOTE(jk0): Eventually extract this out to the ImageService?
210+ def fetch_images():
211+ images = []
212+ marker = None
213+ while True:
214+ batch = image_service.detail(context, filters=filters,
215+ marker=marker, sort_key='created_at', sort_dir='desc')
216+ if not batch:
217+ break
218+ images += batch
219+ marker = batch[-1]['id']
220+ return images
221+
222+ image_service = nova.image.get_default_image_service()
223+ filters = {'property-image_type': 'backup',
224+ 'property-backup_type': backup_type,
225+ 'property-instance_uuid': instance_uuid}
226+
227+ images = fetch_images()
228+ num_images = len(images)
229+ LOG.debug(_("Found %(num_images)d images (rotation: %(rotation)d)"
230+ % locals()))
231+ if num_images > rotation:
232+ # NOTE(sirp): this deletes all backups that exceed the rotation
233+ # limit
234+ excess = len(images) - rotation
235+ LOG.debug(_("Rotating out %d backups" % excess))
236+ for i in xrange(excess):
237+ image = images.pop()
238+ image_id = image['id']
239+ LOG.debug(_("Deleting image %d" % image_id))
240+ image_service.delete(context, image_id)
241+
242 @exception.wrap_exception
243 @checks_instance_lock
244 def set_admin_password(self, context, instance_id, new_pass=None):
245
246=== modified file 'nova/db/sqlalchemy/migrate_repo/versions/027_add_provider_firewall_rules.py'
247--- nova/db/sqlalchemy/migrate_repo/versions/027_add_provider_firewall_rules.py 2011-06-28 22:26:08 +0000
248+++ nova/db/sqlalchemy/migrate_repo/versions/027_add_provider_firewall_rules.py 2011-06-28 23:51:36 +0000
249@@ -58,7 +58,7 @@
250 Column('to_port', Integer()),
251 Column('cidr',
252 String(length=255, convert_unicode=False, assert_unicode=None,
253- unicode_error=None, _warn_on_bytestring=False)))
254+ unicode_error=None, _warn_on_bytestring=False)))
255
256
257 def upgrade(migrate_engine):
258
259=== modified file 'nova/exception.py'
260--- nova/exception.py 2011-06-28 15:13:52 +0000
261+++ nova/exception.py 2011-06-28 23:51:36 +0000
262@@ -558,6 +558,14 @@
263 message = _("Unable to use global role %(role_id)s")
264
265
266+class ImageRotationNotAllowed(NovaException):
267+ message = _("Rotation is not allowed for snapshots")
268+
269+
270+class RotationRequiredForBackup(NovaException):
271+ message = _("Rotation param is required for backup image_type")
272+
273+
274 #TODO(bcwaldon): EOL this exception!
275 class Duplicate(NovaException):
276 pass
277
278=== modified file 'nova/tests/api/openstack/fakes.py'
279--- nova/tests/api/openstack/fakes.py 2011-06-28 00:23:19 +0000
280+++ nova/tests/api/openstack/fakes.py 2011-06-28 23:51:36 +0000
281@@ -147,6 +147,16 @@
282 stubs.Set(nova.compute.API, 'snapshot', snapshot)
283
284
285+def stub_out_compute_api_backup(stubs):
286+ def backup(self, context, instance_id, name, backup_type, rotation,
287+ extra_properties=None):
288+ props = dict(instance_id=instance_id, instance_ref=instance_id,
289+ backup_type=backup_type, rotation=rotation)
290+ props.update(extra_properties or {})
291+ return dict(id='123', status='ACTIVE', name=name, properties=props)
292+ stubs.Set(nova.compute.API, 'backup', backup)
293+
294+
295 def stub_out_glance_add_image(stubs, sent_to_glance):
296 """
297 We return the metadata sent to glance by modifying the sent_to_glance dict
298
299=== modified file 'nova/tests/api/openstack/test_images.py'
300--- nova/tests/api/openstack/test_images.py 2011-06-24 12:01:51 +0000
301+++ nova/tests/api/openstack/test_images.py 2011-06-28 23:51:36 +0000
302@@ -340,6 +340,7 @@
303 self.fixtures = self._make_image_fixtures()
304 fakes.stub_out_glance(self.stubs, initial_fixtures=self.fixtures)
305 fakes.stub_out_compute_api_snapshot(self.stubs)
306+ fakes.stub_out_compute_api_backup(self.stubs)
307
308 def tearDown(self):
309 """Run after each test."""
310@@ -364,10 +365,10 @@
311 response_list = response_dict["images"]
312
313 expected = [{'id': 123, 'name': 'public image'},
314- {'id': 124, 'name': 'queued backup'},
315- {'id': 125, 'name': 'saving backup'},
316- {'id': 126, 'name': 'active backup'},
317- {'id': 127, 'name': 'killed backup'},
318+ {'id': 124, 'name': 'queued snapshot'},
319+ {'id': 125, 'name': 'saving snapshot'},
320+ {'id': 126, 'name': 'active snapshot'},
321+ {'id': 127, 'name': 'killed snapshot'},
322 {'id': 129, 'name': None}]
323
324 self.assertDictListMatch(response_list, expected)
325@@ -617,14 +618,14 @@
326 },
327 {
328 'id': 124,
329- 'name': 'queued backup',
330+ 'name': 'queued snapshot',
331 'updated': self.NOW_API_FORMAT,
332 'created': self.NOW_API_FORMAT,
333 'status': 'QUEUED',
334 },
335 {
336 'id': 125,
337- 'name': 'saving backup',
338+ 'name': 'saving snapshot',
339 'updated': self.NOW_API_FORMAT,
340 'created': self.NOW_API_FORMAT,
341 'status': 'SAVING',
342@@ -632,14 +633,14 @@
343 },
344 {
345 'id': 126,
346- 'name': 'active backup',
347+ 'name': 'active snapshot',
348 'updated': self.NOW_API_FORMAT,
349 'created': self.NOW_API_FORMAT,
350 'status': 'ACTIVE'
351 },
352 {
353 'id': 127,
354- 'name': 'killed backup',
355+ 'name': 'killed snapshot',
356 'updated': self.NOW_API_FORMAT,
357 'created': self.NOW_API_FORMAT,
358 'status': 'FAILED',
359@@ -684,7 +685,7 @@
360 },
361 {
362 'id': 124,
363- 'name': 'queued backup',
364+ 'name': 'queued snapshot',
365 'serverRef': "http://localhost:8774/v1.1/servers/42",
366 'updated': self.NOW_API_FORMAT,
367 'created': self.NOW_API_FORMAT,
368@@ -706,7 +707,7 @@
369 },
370 {
371 'id': 125,
372- 'name': 'saving backup',
373+ 'name': 'saving snapshot',
374 'serverRef': "http://localhost:8774/v1.1/servers/42",
375 'updated': self.NOW_API_FORMAT,
376 'created': self.NOW_API_FORMAT,
377@@ -729,7 +730,7 @@
378 },
379 {
380 'id': 126,
381- 'name': 'active backup',
382+ 'name': 'active snapshot',
383 'serverRef': "http://localhost:8774/v1.1/servers/42",
384 'updated': self.NOW_API_FORMAT,
385 'created': self.NOW_API_FORMAT,
386@@ -751,7 +752,7 @@
387 },
388 {
389 'id': 127,
390- 'name': 'killed backup',
391+ 'name': 'killed snapshot',
392 'serverRef': "http://localhost:8774/v1.1/servers/42",
393 'updated': self.NOW_API_FORMAT,
394 'created': self.NOW_API_FORMAT,
395@@ -969,18 +970,103 @@
396 self.assertEqual(res.status_int, 404)
397
398 def test_create_image(self):
399-
400- body = dict(image=dict(serverId='123', name='Backup 1'))
401- req = webob.Request.blank('/v1.0/images')
402- req.method = 'POST'
403- req.body = json.dumps(body)
404- req.headers["content-type"] = "application/json"
405- response = req.get_response(fakes.wsgi_app())
406- self.assertEqual(200, response.status_int)
407+ body = dict(image=dict(serverId='123', name='Snapshot 1'))
408+ req = webob.Request.blank('/v1.0/images')
409+ req.method = 'POST'
410+ req.body = json.dumps(body)
411+ req.headers["content-type"] = "application/json"
412+ response = req.get_response(fakes.wsgi_app())
413+ self.assertEqual(200, response.status_int)
414+
415+ def test_create_snapshot_no_name(self):
416+ """Name is required for snapshots"""
417+ body = dict(image=dict(serverId='123'))
418+ req = webob.Request.blank('/v1.0/images')
419+ req.method = 'POST'
420+ req.body = json.dumps(body)
421+ req.headers["content-type"] = "application/json"
422+ response = req.get_response(fakes.wsgi_app())
423+ self.assertEqual(400, response.status_int)
424+
425+ def test_create_backup_no_name(self):
426+ """Name is also required for backups"""
427+ body = dict(image=dict(serverId='123', image_type='backup',
428+ backup_type='daily', rotation=1))
429+ req = webob.Request.blank('/v1.0/images')
430+ req.method = 'POST'
431+ req.body = json.dumps(body)
432+ req.headers["content-type"] = "application/json"
433+ response = req.get_response(fakes.wsgi_app())
434+ self.assertEqual(400, response.status_int)
435+
436+ def test_create_backup_with_rotation_and_backup_type(self):
437+ """The happy path for creating backups
438+
439+ Creating a backup is an admin-only operation, as opposed to snapshots
440+ which are available to anybody.
441+ """
442+ # FIXME(sirp): teardown needed?
443+ FLAGS.allow_admin_api = True
444+
445+ # FIXME(sirp): should the fact that backups are admin_only be a FLAG
446+ body = dict(image=dict(serverId='123', image_type='backup',
447+ name='Backup 1',
448+ backup_type='daily', rotation=1))
449+ req = webob.Request.blank('/v1.0/images')
450+ req.method = 'POST'
451+ req.body = json.dumps(body)
452+ req.headers["content-type"] = "application/json"
453+ response = req.get_response(fakes.wsgi_app())
454+ self.assertEqual(200, response.status_int)
455+
456+ def test_create_backup_no_rotation(self):
457+ """Rotation is required for backup requests"""
458+ # FIXME(sirp): teardown needed?
459+ FLAGS.allow_admin_api = True
460+
461+ # FIXME(sirp): should the fact that backups are admin_only be a FLAG
462+ body = dict(image=dict(serverId='123', name='daily',
463+ image_type='backup', backup_type='daily'))
464+ req = webob.Request.blank('/v1.0/images')
465+ req.method = 'POST'
466+ req.body = json.dumps(body)
467+ req.headers["content-type"] = "application/json"
468+ response = req.get_response(fakes.wsgi_app())
469+ self.assertEqual(400, response.status_int)
470+
471+ def test_create_backup_no_backup_type(self):
472+ """Backup Type (daily or weekly) is required for backup requests"""
473+ # FIXME(sirp): teardown needed?
474+ FLAGS.allow_admin_api = True
475+
476+ # FIXME(sirp): should the fact that backups are admin_only be a FLAG
477+ body = dict(image=dict(serverId='123', name='daily',
478+ image_type='backup', rotation=1))
479+ req = webob.Request.blank('/v1.0/images')
480+ req.method = 'POST'
481+ req.body = json.dumps(body)
482+ req.headers["content-type"] = "application/json"
483+ response = req.get_response(fakes.wsgi_app())
484+ self.assertEqual(400, response.status_int)
485+
486+ def test_create_image_with_invalid_image_type(self):
487+ """Valid image_types are snapshot | daily | weekly"""
488+ # FIXME(sirp): teardown needed?
489+ FLAGS.allow_admin_api = True
490+
491+ # FIXME(sirp): should the fact that backups are admin_only be a FLAG
492+ body = dict(image=dict(serverId='123', image_type='monthly',
493+ rotation=1))
494+ req = webob.Request.blank('/v1.0/images')
495+ req.method = 'POST'
496+ req.body = json.dumps(body)
497+ req.headers["content-type"] = "application/json"
498+ response = req.get_response(fakes.wsgi_app())
499+ self.assertEqual(400, response.status_int)
500
501 def test_create_image_no_server_id(self):
502
503- body = dict(image=dict(name='Backup 1'))
504+ body = dict(image=dict(name='Snapshot 1'))
505 req = webob.Request.blank('/v1.0/images')
506 req.method = 'POST'
507 req.body = json.dumps(body)
508@@ -990,7 +1076,7 @@
509
510 def test_create_image_v1_1(self):
511
512- body = dict(image=dict(serverRef='123', name='Backup 1'))
513+ body = dict(image=dict(serverRef='123', name='Snapshot 1'))
514 req = webob.Request.blank('/v1.1/images')
515 req.method = 'POST'
516 req.body = json.dumps(body)
517@@ -1024,7 +1110,7 @@
518
519 def test_create_image_v1_1_xml_serialization(self):
520
521- body = dict(image=dict(serverRef='123', name='Backup 1'))
522+ body = dict(image=dict(serverRef='123', name='Snapshot 1'))
523 req = webob.Request.blank('/v1.1/images')
524 req.method = 'POST'
525 req.body = json.dumps(body)
526@@ -1038,7 +1124,7 @@
527 <image
528 created="None"
529 id="123"
530- name="Backup 1"
531+ name="Snapshot 1"
532 serverRef="http://localhost/v1.1/servers/123"
533 status="ACTIVE"
534 updated="None"
535@@ -1057,7 +1143,7 @@
536
537 def test_create_image_v1_1_no_server_ref(self):
538
539- body = dict(image=dict(name='Backup 1'))
540+ body = dict(image=dict(name='Snapshot 1'))
541 req = webob.Request.blank('/v1.1/images')
542 req.method = 'POST'
543 req.body = json.dumps(body)
544@@ -1084,19 +1170,21 @@
545 status='active', properties={})
546 image_id += 1
547
548- # Backup for User 1
549+ # Snapshot for User 1
550 server_ref = 'http://localhost:8774/v1.1/servers/42'
551- backup_properties = {'instance_ref': server_ref, 'user_id': '1'}
552+ snapshot_properties = {'instance_ref': server_ref, 'user_id': '1'}
553 for status in ('queued', 'saving', 'active', 'killed'):
554- add_fixture(id=image_id, name='%s backup' % status,
555+ add_fixture(id=image_id, name='%s snapshot' % status,
556 is_public=False, status=status,
557- properties=backup_properties)
558+ properties=snapshot_properties)
559 image_id += 1
560
561- # Backup for User 2
562- other_backup_properties = {'instance_id': '43', 'user_id': '2'}
563- add_fixture(id=image_id, name='someone elses backup', is_public=False,
564- status='active', properties=other_backup_properties)
565+ # Snapshot for User 2
566+ other_snapshot_properties = {'instance_id': '43', 'user_id': '2'}
567+ add_fixture(id=image_id, name='someone elses snapshot',
568+ is_public=False, status='active',
569+ properties=other_snapshot_properties)
570+
571 image_id += 1
572
573 # Image without a name