Source code for storage_api.apis.storage

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Copyright (C) 2016, CERN
# This software is distributed under the terms of the GNU General Public
# Licence version 3 (GPL Version 3), copied verbatim in the file "LICENSE".
# In applying this license, CERN does not waive the privileges and immunities
# granted to it by virtue of its status as Intergovernmental Organization
# or submit itself to any jurisdiction.

import storage_api.apis
from storage_api.apis.common.auth import in_role
from storage_api.apis.common import ADMIN_ROLE, UBER_ADMIN_ROLE, USER_ROLE
from storage_api.utils import dict_without, filter_none

import logging
import traceback
from contextlib import contextmanager
from functools import partial
import re

from flask_restplus import Namespace, Resource, fields, marshal
from flask import current_app
from netapp.api import APIError

api = Namespace('/',
                description='Storage operations')

log = logging.getLogger(__name__)
log.addHandler(logging.NullHandler())


VOLUME_NAME_DESCRIPTION = ("The name of the volume. "
                           "Must not contain leading /. On NetApp back-ends,"
                           " this may either be the name of a volume, or"
                           " node_name:/junction/path.")

SUBSYSTEM_DESCRIPTION = "The subsystem to run the command on."

DISALLOWED_VOLUME_NAME_RE = re.compile(".*[^a-z0-9:/_\.-].*")


@contextmanager
[docs]def exception_is_errorcode(api, exception, error_code, message=None): """ Context to make sure all Exceptions of a given type calls an api.abort with a suitable error and corresponding message. If no message is passed, use str() on the exception. """ try: yield except exception as e: message = str(e) if message is None else message api.abort(error_code, message)
keyerror_is_404 = partial(exception_is_errorcode, api=api, exception=KeyError, error_code=404) keyerror_is_400 = partial(exception_is_errorcode, api=api, exception=KeyError, error_code=400) valueerror_is_400 = partial(exception_is_errorcode, api=api, exception=ValueError, error_code=400)
[docs]def backend(backend_name): """ Return the actual backend object as given by backend_name. Has to be a function due to the app context not being available until later. """ with exception_is_errorcode(api, KeyError, 404, message=("No such subsystem. " "Allowed values are: {}") .format(", ".join( current_app.config['SUBSYSTEM'].keys()))): instance_id = current_app.config['SUBSYSTEM'][backend_name] return current_app.extensions[instance_id]
policy_rule_list_field = fields.List(fields.String( example="10.10.10.1/24", min_length=1)) volume_write_model = api.model('VolumeWrite', { 'autosize_enabled': fields.Boolean(), 'autosize_increment': fields.Integer(), 'max_autosize': fields.Integer(), 'active_policy_name': fields.String(min_length=1), 'percentage_snapshot_reserve': fields.Integer(), 'compression_enabled': fields.Boolean(), 'inline_compression': fields.Boolean(), 'caching_policy': fields.String(), }) volume_create_model = api.inherit('VolumeCreate', volume_write_model, { 'name': fields.String( min_length=1, description="The internal name of the volume", example="volume_name", ), 'size_total': fields.Integer( description=("The total size of the volume, " " in bytes. If creating, the size" " of the volume.") ), 'aggregate_name': fields.String( min_length=1, required=False, description=("If applicable, the" " aggregate to place the volume in" " (NetApp only). If not provided, will" " use the one with the most free" " space.") ), 'junction_path': fields.String(min_length=1), }) volume_read_model = api.inherit('VolumeRead', volume_create_model, { 'size_used': fields.Integer(), 'uuid': fields.String(min_length=1), 'state': fields.String(min_length=1), 'filer_address': fields.String(min_length=1), 'creation_time': fields.DateTime(dt_format='rfc822'), 'percentage_snapshot_reserve_used': fields.Integer(), }) lock_model = api.model('Lock', { 'host': fields.String(min_length=1, required=True, example="dbthing.cern.ch") }) export_policy_model = api.model('Export Policy', { 'name': fields.String(min_length=1, example="allow_cluster_x"), 'rules': policy_rule_list_field }) snapshot_model = api.model('Snapshot', { 'name': fields.String(), }) volume_create_w_snapshot_model = api.inherit( 'OptionalFromSnapshot', volume_create_model, { 'from_snapshot': fields.String( required=False, description=("The snapshot name to create from/restore") ), 'from_volume': fields.String( required=False, description=("When cloning a volume, use this volume" " as basis for the snapshot to start" " from"), )}) snapshot_put_model = api.model('SnapshotPut', { 'purge_old_if_needed': fields.Boolean( description=("If `true`, purge the oldest snapshot iff necessary " " to create a new one"))}) policy_rule_write_model = api.model('PolicyRule', {'rules': policy_rule_list_field}) @api.errorhandler(APIError)
[docs]def handle_netapp_exception(error): '''Return the error message from the filer and 500 status code''' return_message = {'message': error.msg, "errno": error.errno} if current_app.debug: return_message['failing_query'] = str(error.failing_query) return return_message, 500
@api.errorhandler
[docs]def default_error_handler(error): # pragma: no cover log.warning(traceback.format_exc()) return {'message': str(error)}, getattr(error, 'code', 500)
@api.route('/<string:subsystem>/volumes') @api.param('subsystem', SUBSYSTEM_DESCRIPTION)
[docs]class AllVolumes(Resource): @api.doc(description="Get a list of all volumes", id='get_volumes') @api.marshal_with(volume_read_model, as_list=True) @in_role(api, USER_ROLE)
[docs] def get(self, subsystem): return backend(subsystem).volumes
@in_role(api, USER_ROLE) @api.route('/<string:subsystem>/volumes/<path:volume_name>') @api.param('subsystem', SUBSYSTEM_DESCRIPTION) @api.param('volume_name', VOLUME_NAME_DESCRIPTION)
[docs]class Volume(Resource): @api.marshal_with(volume_read_model, description="The volume named volume_name") @api.doc(description="Get a specific volume by name") @api.response(404, description="No such volume exists") @api.response(201, description="A new volume was created") @in_role(api, USER_ROLE) def get(self, subsystem, volume_name): log.info("GET for {}".format(volume_name)) if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") with keyerror_is_404(): return backend(subsystem).get_volume(volume_name) @api.doc(body=volume_create_w_snapshot_model, description=("Create a new volume with the given details. " " If `from_snapshot` is a snapshot and `volume_name`" " already refers to an existing volume, roll back " "that volume to that snapshot. If `from_snapshot` " "is a snapshot, `from_volume` is an existing volume " "and `volume_name` doesn't already exist, create a " "clone of `from_volume` named `volume_name`, in the " "state at `from_snapshot`.")) @api.expect(volume_create_w_snapshot_model, validate=True) @api.marshal_with(volume_read_model, description=("The newly created volume" " (if created), otherwise nothing")) @in_role(api, ADMIN_ROLE) def post(self, subsystem, volume_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") data = marshal(storage_api.apis.api.payload, volume_create_w_snapshot_model) if data['from_volume'] and data['from_snapshot']: with keyerror_is_404(), valueerror_is_400(): new_vol = backend(subsystem).clone_volume( volume_name, data['from_volume'], data['from_snapshot']) elif data['from_snapshot']: with keyerror_is_404(): new_vol = backend(subsystem).rollback_volume( volume_name, restore_snapshot_name=data['from_snapshot']) else: with valueerror_is_400(), keyerror_is_400(): new_vol = backend(subsystem).create_volume( volume_name, **dict_without(dict(data), 'from_snapshot', 'from_volume')) return new_vol, 201 @api.doc(description=("Restrict the volume named *volume_name*" " but do not actually delete it")) @api.response(204, description="Successfully restricted", model=None) @in_role(api, UBER_ADMIN_ROLE) def delete(self, subsystem, volume_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") with keyerror_is_404(): backend(subsystem).restrict_volume(volume_name) return '', 204 @api.doc(description="Partially update volume_name") @api.expect(volume_write_model, validate=True) @in_role(api, UBER_ADMIN_ROLE) def patch(self, subsystem, volume_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") data = filter_none(marshal(storage_api.apis.api.payload, volume_write_model)) log.info("PATCH with payload {}".format(str(data))) if data: with keyerror_is_404(): backend(subsystem).patch_volume(volume_name, **data) else: raise api.abort(400, "No PATCH data provided!")
@api.route('/<string:subsystem>/volumes/<path:volume_name>/snapshots') @api.param('subsystem', SUBSYSTEM_DESCRIPTION) @api.param('volume_name', VOLUME_NAME_DESCRIPTION)
[docs]class AllSnapshots(Resource): @api.marshal_with(snapshot_model, description="All snapshots for the volume", as_list=True)
[docs] def get(self, subsystem, volume_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") return backend(subsystem).get_snapshots(volume_name)
@api.route(('/<string:subsystem>/volumes/' '<path:volume_name>/snapshots/<string:snapshot_name>')) @api.param('subsystem', SUBSYSTEM_DESCRIPTION) @api.param('volume_name', VOLUME_NAME_DESCRIPTION) @api.param('snapshot_name', 'The snapshot name')
[docs]class Snapshots(Resource): @api.doc(description="Get the current information for a given snapshot") @api.marshal_with(snapshot_model)
[docs] def get(self, subsystem, volume_name, snapshot_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") with keyerror_is_404(): return backend(subsystem).get_snapshot(volume_name, snapshot_name)
@api.response(409, description=("Too many snapshots, cannot create" " another. Try" " `purge_old_if_needed=true`.")) @api.response(201, description="Successfully created a snapshot") @api.expect(snapshot_put_model) @api.doc(description=("Create a new snapshot of *volume_name*" " under *snapshot_name*"))
[docs] def post(self, subsystem, volume_name, snapshot_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") log.info("Creating snapshot {} for volume {}" .format(snapshot_name, volume_name)) with keyerror_is_404(), valueerror_is_400(): backend(subsystem).create_snapshot(volume_name, snapshot_name) return '', 201
@api.doc(description=("Delete the snapshot")) @api.response(204, description="Successfully deleted", model=None) @api.response(404, description="No such snapshot", model=None) @in_role(api, ADMIN_ROLE)
[docs] def delete(self, subsystem, volume_name, snapshot_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") with keyerror_is_404(): backend(subsystem).delete_snapshot(volume_name, snapshot_name) return '', 204
@api.route('/<string:subsystem>/volumes/<path:volume_name>/locks') @api.param('subsystem', SUBSYSTEM_DESCRIPTION) @api.param('volume_name', VOLUME_NAME_DESCRIPTION) @api.doc(description="Get the host locking the volume, if any")
[docs]class AllLocks(Resource): @api.marshal_with(lock_model, as_list=True, description=("An empty list (if no locks were" " held) or a single dict describing the" " host holding the lock")) @in_role(api, USER_ROLE)
[docs] def get(self, subsystem, volume_name): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") with keyerror_is_404(): mby_lock = backend(subsystem).locks(volume_name) return [] if mby_lock is None else [{"host": mby_lock}]
@api.route(('/<string:subsystem>/volumes/' '<path:volume_name>/locks/<string:host>')) @api.param('subsystem', SUBSYSTEM_DESCRIPTION) @api.param('volume_name', VOLUME_NAME_DESCRIPTION) @api.param('host', "the host holding the lock in question")
[docs]class Locks(Resource): @api.doc(description="Lock the volume with host holding the lock") @api.response(201, "A new lock was added") @in_role(api, ADMIN_ROLE)
[docs] def put(self, subsystem, volume_name, host): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") with valueerror_is_400(), keyerror_is_404(): backend(subsystem).create_lock(volume_name, host) return '', 201
@api.doc(description="Force the lock for the host") @api.response(204, description="Lock successfully forced") @in_role(api, UBER_ADMIN_ROLE)
[docs] def delete(self, subsystem, volume_name, host): if DISALLOWED_VOLUME_NAME_RE.match(volume_name): api.abort(400, "Invalid volume name") with keyerror_is_404(): backend(subsystem).remove_lock(volume_name, host) return '', 204
@api.route('/<string:subsystem>/export') @api.param('subsystem', SUBSYSTEM_DESCRIPTION)
[docs]class AllExports(Resource): @api.marshal_with(export_policy_model, description="The full policy", as_list=True) @api.doc(description="Get all ACLs present on the back-end") @in_role(api, ADMIN_ROLE)
[docs] def get(self, subsystem): return backend(subsystem).policies
@api.route('/<string:subsystem>/export/<string:policy>') @api.param('subsystem', SUBSYSTEM_DESCRIPTION) @api.param('policy', "The policy to operate on")
[docs]class Export(Resource): @api.marshal_with(export_policy_model, description="Get the rules of a specific policy") @api.doc(description="Display the rules of a given policy") @in_role(api, ADMIN_ROLE)
[docs] def get(self, subsystem, policy): with keyerror_is_404(): rules = backend(subsystem).get_policy(policy) return {'name': policy, 'rules': rules}
@api.doc(description=("Grant hosts matching" " a given pattern access to the given volume")) @api.response(201, description="The provided access rules were added") @api.response(400, description="A policy with that name already exists") @api.expect(policy_rule_write_model, validate=True) @in_role(api, ADMIN_ROLE)
[docs] def post(self, subsystem, policy): rules = marshal(storage_api.apis.api.payload, policy_rule_write_model)['rules'] with keyerror_is_404(), valueerror_is_400(): backend(subsystem).create_policy(policy, rules) return '', 201
@api.doc(description=("Delete the entire policy")) @api.response(204, description="Successfully deleted the policy") @api.response(404, description="No such policy exists") @in_role(api, ADMIN_ROLE)
[docs] def delete(self, subsystem, policy): with keyerror_is_404(): backend(subsystem).remove_policy(policy) return '', 204
@api.route('/<string:subsystem>/export/<string:policy>/<path:rule>') @api.param('subsystem', SUBSYSTEM_DESCRIPTION) @api.param('policy', "The policy to operate on") @api.param('rule', "The policy rule to operate on")
[docs]class ExportRule(Resource): @api.doc(description="Grant hosts matching a given pattern access") @api.response(201, description=("The provided access rule" " was added or already present")) @in_role(api, ADMIN_ROLE)
[docs] def put(self, subsystem, policy, rule): backend(subsystem).ensure_policy_rule_present(policy, rule) return '', 201
@api.doc(description=("Delete rule from policy")) @api.response(204, description=("Successfully deleted the rule," " or rule did not exist")) @api.response(404, description="No such policy exists") @in_role(api, ADMIN_ROLE)
[docs] def delete(self, subsystem, policy, rule): backend(subsystem).ensure_policy_rule_absent(policy, rule) return '', 204