Coverage for manila/cmd/manage.py: 78%
313 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1#!/usr/bin/env python3
3# Copyright (c) 2011 X.commerce, a business unit of eBay Inc.
4# Copyright 2010 United States Government as represented by the
5# Administrator of the National Aeronautics and Space Administration.
6# All Rights Reserved.
7#
8# Licensed under the Apache License, Version 2.0 (the "License"); you may
9# not use this file except in compliance with the License. You may obtain
10# a copy of the License at
11#
12# http://www.apache.org/licenses/LICENSE-2.0
13#
14# Unless required by applicable law or agreed to in writing, software
15# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
17# License for the specific language governing permissions and limitations
18# under the License.
20# Interactive shell based on Django:
21#
22# Copyright (c) 2005, the Lawrence Journal-World
23# All rights reserved.
24#
25# Redistribution and use in source and binary forms, with or without
26# modification, are permitted provided that the following conditions are met:
27#
28# 1. Redistributions of source code must retain the above copyright notice,
29# this list of conditions and the following disclaimer.
30#
31# 2. Redistributions in binary form must reproduce the above copyright
32# notice, this list of conditions and the following disclaimer in the
33# documentation and/or other materials provided with the distribution.
34#
35# 3. Neither the name of Django nor the names of its contributors may be
36# used to endorse or promote products derived from this software without
37# specific prior written permission.
38#
39# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
40# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
41# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
42# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
43# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
44# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
45# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
46# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
47# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
48# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
49# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
52"""
53 CLI interface for manila management.
54"""
56import os
57import sys
58import yaml
60from oslo_config import cfg
61from oslo_log import log
62from oslo_serialization import jsonutils
64from manila.common import config # Need to register global_opts # noqa
65from manila import context
66from manila import db
67from manila.db import migration
68from manila.i18n import _
69from manila import utils
70from manila import version
72CONF = cfg.CONF
74ALLOWED_OUTPUT_FORMATS = ['table', 'json', 'yaml']
75HOST_UPDATE_HELP_MSG = ("A fully qualified host string is of the format "
76 "'HostA@BackendB#PoolC'. Provide only the host name "
77 "(ex: 'HostA') to update the hostname part of "
78 "the host string. Provide only the "
79 "host name and backend name (ex: 'HostA@BackendB') to "
80 "update the host and backend names.")
81HOST_UPDATE_CURRENT_HOST_HELP = ("Current share host name. %s" %
82 HOST_UPDATE_HELP_MSG)
83HOST_UPDATE_NEW_HOST_HELP = "New share host name. %s" % HOST_UPDATE_HELP_MSG
84LIST_OUTPUT_FORMAT_HELP = ("Format to be used to print the output (table, "
85 "json, yaml). Defaults to 'table'")
86SHARE_SERVERS_UPDATE_HELP = ("List of share servers to be updated, separated "
87 "by commas.")
88SHARE_SERVERS_UPDATE_CAPABILITIES_HELP = (
89 "List of share server capabilities to be updated, separated by commas.")
90SHARE_DELETE_HELP = ("Share ID to be deleted.")
93# Decorators for actions
94def args(*args, **kwargs):
95 def _decorator(func):
96 func.__dict__.setdefault('args', []).insert(0, (args, kwargs))
97 return func
98 return _decorator
101class ListCommand(object):
103 def list_json(self, resource_name, resource_list):
104 resource_list = {resource_name: resource_list}
105 object_list = jsonutils.dumps(resource_list, indent=4)
106 print(object_list)
108 def list_yaml(self, resource_name, resource_list):
109 resource_list = {resource_name: resource_list}
110 data_yaml = yaml.dump(resource_list)
111 print(data_yaml)
113 def list_table(self, resource_name, resource_list):
114 print_format = "{0:<16} {1:<36} {2:<16} {3:<10} {4:<5} {5:<10}"
115 print(print_format.format(
116 *[k.capitalize().replace(
117 '_', ' ') for k in resource_list[0].keys()]))
118 for resource in resource_list:
119 # Print is not transforming into a string, so let's ensure it
120 # happens
121 resource['updated_at'] = str(resource['updated_at'])
122 print(print_format.format(*resource.values()))
124 def _check_format_output(self, format_output):
125 if format_output not in ALLOWED_OUTPUT_FORMATS: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true
126 print('Invalid output format specified. Defaulting to table.')
127 return 'table'
128 else:
129 return format_output
132class ShellCommands(object):
133 def bpython(self):
134 """Runs a bpython shell.
136 Falls back to Ipython/python shell if unavailable
137 """
138 self.run('bpython')
140 def ipython(self):
141 """Runs an Ipython shell.
143 Falls back to Python shell if unavailable
144 """
145 self.run('ipython')
147 def python(self):
148 """Runs a python shell.
150 Falls back to Python shell if unavailable
151 """
152 self.run('python')
154 @args('--shell', dest="shell",
155 metavar='<bpython|ipython|python>',
156 help='Python shell')
157 def run(self, shell=None):
158 """Runs a Python interactive interpreter."""
159 if not shell: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 shell = 'bpython'
162 if shell == 'bpython': 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 try:
164 import bpython
165 bpython.embed()
166 except ImportError:
167 shell = 'ipython'
168 if shell == 'ipython': 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 try:
170 from IPython import embed
171 embed()
172 except ImportError:
173 # Ipython < 0.11
174 try:
175 import IPython
177 # Explicitly pass an empty list as arguments, because
178 # otherwise IPython would use sys.argv from this script.
179 shell = IPython.Shell.IPShell(argv=[])
180 shell.mainloop()
181 except ImportError:
182 # no IPython module
183 shell = 'python'
185 if shell == 'python': 185 ↛ exitline 185 didn't return from function 'run' because the condition on line 185 was always true
186 import code
187 try:
188 # Try activating rlcompleter, because it's handy.
189 import readline
190 except ImportError:
191 pass
192 else:
193 # We don't have to wrap the following import in a 'try',
194 # because we already know 'readline' was imported successfully.
195 import rlcompleter # noqa
196 readline.parse_and_bind("tab:complete")
197 code.interact()
199 @args('--path', required=True, help='Script path')
200 def script(self, path):
201 """Runs the script from the specified path with flags set properly.
203 arguments: path
204 """
205 exec(compile(open(path).read(), path, 'exec'), locals(), globals())
208class HostCommands(object):
209 """List hosts."""
211 @args('zone', nargs='?', default=None,
212 help='Availability Zone (default: %(default)s)')
213 def list(self, zone=None):
214 """Show a list of all physical hosts. Filter by zone.
216 args: [zone]
217 """
218 print("%-25s\t%-15s" % (_('host'), _('zone')))
219 ctxt = context.get_admin_context()
220 services = db.service_get_all(ctxt)
221 if zone:
222 services = [
223 s for s in services if s['availability_zone']['name'] == zone]
224 hosts = []
225 for srv in services:
226 if not [h for h in hosts if h['host'] == srv['host']]: 226 ↛ 225line 226 didn't jump to line 225 because the condition on line 226 was always true
227 hosts.append(srv)
229 for h in hosts:
230 print("%-25s\t%-15s" % (h['host'], h['availability_zone']['name']))
233class DbCommands(object):
234 """Class for managing the database."""
236 def __init__(self):
237 pass
239 @args('version', nargs='?', default=None,
240 help='Database version')
241 def sync(self, version=None):
242 """Sync the database up to the most recent version."""
243 return migration.upgrade(version)
245 def version(self):
246 """Print the current database version."""
247 print(migration.version())
249 # NOTE(imalinovskiy):
250 # Manila init migration hardcoded here,
251 # because alembic has strange behaviour:
252 # downgrade base = downgrade from head(162a3e673105) -> base(162a3e673105)
253 # = downgrade from 162a3e673105 -> (empty) [ERROR]
254 # downgrade 162a3e673105 = downgrade from head(162a3e673105)->162a3e673105
255 # = do nothing [OK]
256 @args('version', nargs='?', default='162a3e673105',
257 help='Version to downgrade')
258 def downgrade(self, version=None):
259 """Downgrade database to the given version."""
260 return migration.downgrade(version)
262 @args('--message', help='Revision message')
263 @args('--autogenerate', help='Autogenerate migration from schema')
264 def revision(self, message, autogenerate):
265 """Generate new migration."""
266 return migration.revision(message, autogenerate)
268 @args('version', nargs='?', default=None,
269 help='Version to stamp version table with')
270 def stamp(self, version=None):
271 """Stamp the version table with the given version."""
272 return migration.stamp(version)
274 @args('age_in_days', type=int, default=0, nargs='?',
275 help='A non-negative integer, denoting the age of soft-deleted '
276 'records in number of days. 0 can be specified to purge all '
277 'soft-deleted rows, default is %(default)d.')
278 def purge(self, age_in_days):
279 """Purge soft-deleted records older than a given age."""
280 age_in_days = int(age_in_days)
281 if age_in_days < 0:
282 print(_("Must supply a non-negative value for age."))
283 exit(1)
284 ctxt = context.get_admin_context()
285 db.purge_deleted_records(ctxt, age_in_days)
288class VersionCommands(object):
289 """Class for exposing the codebase version."""
291 def list(self):
292 print(version.version_string())
294 def __call__(self):
295 self.list()
298class ConfigCommands(object):
299 """Class for exposing the flags defined by flag_file(s)."""
301 def list(self):
302 for key, value in CONF.items():
303 if value is not None:
304 print('%s = %s' % (key, value))
307class GetLogCommands(object):
308 """Get logging information."""
310 def errors(self):
311 """Get all of the errors from the log files."""
312 error_found = 0
313 if CONF.log_dir:
314 logs = [x for x in os.listdir(CONF.log_dir) if x.endswith('.log')]
315 for file in logs:
316 log_file = os.path.join(CONF.log_dir, file)
317 lines = [line.strip() for line in open(log_file, "r")]
318 lines.reverse()
319 print_name = 0
320 for index, line in enumerate(lines):
321 if line.find(" ERROR ") > 0: 321 ↛ 320line 321 didn't jump to line 320 because the condition on line 321 was always true
322 error_found += 1
323 if print_name == 0: 323 ↛ 326line 323 didn't jump to line 326 because the condition on line 323 was always true
324 print(log_file + ":-")
325 print_name = 1
326 print("Line %d : %s" % (len(lines) - index, line))
327 if error_found == 0:
328 print("No errors in logfiles!")
330 @args('num_entries', nargs='?', type=int, default=10,
331 help='Number of entries to list (default: %(default)d)')
332 def syslog(self, num_entries=10):
333 """Get <num_entries> of the manila syslog events."""
334 entries = int(num_entries)
335 count = 0
336 log_file = ''
337 if os.path.exists('/var/log/syslog'): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true
338 log_file = '/var/log/syslog'
339 elif os.path.exists('/var/log/messages'): 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true
340 log_file = '/var/log/messages'
341 else:
342 print("Unable to find system log file!")
343 sys.exit(1)
344 lines = [line.strip() for line in open(log_file, "r")]
345 lines.reverse()
346 print("Last %s manila syslog entries:-" % (entries))
347 for line in lines:
348 if line.find("manila") > 0:
349 count += 1
350 print("%s" % (line))
351 if count == entries:
352 break
354 if count == 0:
355 print("No manila entries in syslog!")
358class ServiceCommands(ListCommand):
359 """Methods for managing services."""
361 @args('--format_output', required=False, default='table',
362 help=LIST_OUTPUT_FORMAT_HELP)
363 def list(self, format_output):
364 """Show a list of all manila services."""
365 ctxt = context.get_admin_context()
366 services = db.service_get_all(ctxt)
367 format_output = self._check_format_output(format_output)
369 services_list = []
370 for service in services:
371 alive = utils.service_is_up(service)
372 state = ":-)" if alive else "XXX"
373 status = 'enabled'
374 if service['disabled']: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true
375 status = 'disabled'
376 services_list.append({
377 'binary': service['binary'],
378 'host': service['host'].partition('.')[0],
379 'zone': service['availability_zone']['name'],
380 'status': status,
381 'state': state,
382 'updated_at': str(service['updated_at']),
383 })
385 method_list_name = f'list_{format_output}'
386 getattr(self, method_list_name)('services', services_list)
388 def cleanup(self):
389 """Remove manila services reporting as 'down'."""
390 ctxt = context.get_admin_context()
391 services = db.service_get_all(ctxt)
393 for svc in services:
394 if utils.service_is_up(svc):
395 continue
396 db.service_destroy(ctxt, svc['id'])
397 print("Cleaned up service %s" % svc['host'])
400class ShareCommands(object):
402 @staticmethod
403 def _validate_hosts(current_host, new_host):
404 err = None
405 if '@' in current_host:
406 if '#' in current_host and '#' not in new_host:
407 err = "%(chost)s specifies a pool but %(nhost)s does not."
408 elif '@' not in new_host:
409 err = "%(chost)s specifies a backend but %(nhost)s does not."
410 if err:
411 print(err % {'chost': current_host, 'nhost': new_host})
412 sys.exit(1)
414 @args('--currenthost', required=True, help=HOST_UPDATE_CURRENT_HOST_HELP)
415 @args('--newhost', required=True, help=HOST_UPDATE_NEW_HOST_HELP)
416 @args('--force', required=False, type=bool, default=False,
417 help="Ignore validations.")
418 def update_host(self, current_host, new_host, force=False):
419 """Modify the host name associated with resources.
421 Particularly to recover from cases where one has moved
422 their Manila Share node, or modified their 'host' opt
423 or their backend section name in the manila configuration file.
424 Affects shares, share servers and share groups
425 """
426 if not force:
427 self._validate_hosts(current_host, new_host)
428 ctxt = context.get_admin_context()
429 updated = db.share_resources_host_update(ctxt, current_host, new_host)
430 msg = ("Updated host of %(si_count)d share instances, "
431 "%(sg_count)d share groups and %(ss_count)d share servers on "
432 "%(chost)s to %(nhost)s.")
433 msg_args = {
434 'si_count': updated['instances'],
435 'sg_count': updated['groups'],
436 'ss_count': updated['servers'],
437 'chost': current_host,
438 'nhost': new_host,
439 }
440 print(msg % msg_args)
442 @args('--share_id', required=True, help=SHARE_DELETE_HELP)
443 def delete(self, share_id):
444 """Delete manila share from the database.
446 This command is useful after a share's manager service
447 has been decommissioned.
448 """
449 ctxt = context.get_admin_context()
450 share = db.share_get(ctxt, share_id)
452 active_replicas = []
453 # We delete "active" replicas at the end
454 for share_instance in share['instances']:
455 if share_instance['replica_state'] == "active":
456 active_replicas.append(share_instance)
457 else:
458 db.share_instance_delete(ctxt, share_instance['id'])
459 for share_instance in active_replicas:
460 db.share_instance_delete(ctxt, share_instance['id'])
461 print("Deleted share instance %s" % share_instance['id'])
463 # finally, clean up the share
464 print("Deleted share %s" % share_id)
467class ShareServerCommands(object):
468 @args('--share_servers', required=True,
469 help=SHARE_SERVERS_UPDATE_HELP)
470 @args('--capabilities', required=True,
471 help=SHARE_SERVERS_UPDATE_CAPABILITIES_HELP)
472 @args('--value', required=False, type=bool, default=False,
473 help="If those capabilities will be enabled (True) or disabled "
474 "(False)")
475 def update_share_server_capabilities(self, share_servers, capabilities,
476 value=False):
477 """Update the share server capabilities.
479 This method receives a list of share servers and capabilities
480 in order to have it updated with the value specified. If the value
481 was not specified the default is False.
482 """
483 share_servers = [server.strip() for server in share_servers.split(",")]
484 capabilities = [cap.strip() for cap in capabilities.split(",")]
485 supported_capabilities = ['security_service_update_support',
486 'network_allocation_update_support']
488 values = dict()
489 for capability in capabilities:
490 if capability not in supported_capabilities:
491 print("One or more capabilities are invalid for this "
492 "operation. The supported capability(ies) is(are) %s."
493 % supported_capabilities)
494 sys.exit(1)
495 values[capability] = value
497 ctxt = context.get_admin_context()
498 db.share_servers_update(ctxt, share_servers, values)
499 print("The capability(ies) %s of the following share server(s)"
500 " %s was(were) updated to %s." %
501 (capabilities, share_servers, value))
504CATEGORIES = {
505 'config': ConfigCommands,
506 'db': DbCommands,
507 'host': HostCommands,
508 'logs': GetLogCommands,
509 'service': ServiceCommands,
510 'share': ShareCommands,
511 'share_server': ShareServerCommands,
512 'shell': ShellCommands,
513 'version': VersionCommands
514}
517def methods_of(obj):
518 """Get all callable methods of an object that don't start with underscore.
520 Returns a list of tuples of the form (method_name, method).
521 """
522 result = []
523 for i in dir(obj):
524 if callable(getattr(obj, i)) and not i.startswith('_'):
525 result.append((i, getattr(obj, i)))
526 return result
529def add_command_parsers(subparsers):
530 for category in CATEGORIES:
531 command_object = CATEGORIES[category]()
533 parser = subparsers.add_parser(category)
534 parser.set_defaults(command_object=command_object)
536 category_subparsers = parser.add_subparsers(dest='action')
538 for (action, action_fn) in methods_of(command_object):
539 parser = category_subparsers.add_parser(action)
541 action_kwargs = []
542 for args, kwargs in getattr(action_fn, 'args', []):
543 parser.add_argument(*args, **kwargs)
545 parser.set_defaults(action_fn=action_fn)
546 parser.set_defaults(action_kwargs=action_kwargs)
549category_opt = cfg.SubCommandOpt('category',
550 title='Command categories',
551 handler=add_command_parsers)
554def get_arg_string(args):
555 arg = None
556 if args[0] == '-':
557 # (Note)zhiteng: args starts with CONF.oparser.prefix_chars
558 # is optional args. Notice that cfg module takes care of
559 # actual ArgParser so prefix_chars is always '-'.
560 if args[1] == '-':
561 # This is long optional arg
562 arg = args[2:]
563 else:
564 arg = args[1:]
565 else:
566 arg = args
568 return arg
571def fetch_func_args(func):
572 fn_args = []
573 for args, kwargs in getattr(func, 'args', []): 573 ↛ 574line 573 didn't jump to line 574 because the loop on line 573 never started
574 arg = get_arg_string(args[0])
575 fn_args.append(getattr(CONF.category, arg))
577 return fn_args
580def main():
581 """Parse options and call the appropriate class/method."""
582 CONF.register_cli_opt(category_opt)
583 script_name = sys.argv[0]
584 if len(sys.argv) < 2:
585 print(_("\nOpenStack manila version: %(version)s\n") %
586 {'version': version.version_string()})
587 print(script_name + " category action [<args>]")
588 print(_("Available categories:"))
589 for category in CATEGORIES:
590 print("\t%s" % category)
591 sys.exit(2)
593 try:
594 log.register_options(CONF)
595 CONF(sys.argv[1:], project='manila',
596 version=version.version_string())
597 log.setup(CONF, "manila")
598 except cfg.ConfigFilesNotFoundError as e:
599 cfg_files = e.config_files
600 print(_("Failed to read configuration file(s): %s") % cfg_files)
601 sys.exit(2)
603 fn = CONF.category.action_fn
605 fn_args = fetch_func_args(fn)
606 fn(*fn_args)
609if __name__ == '__main__': 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true
610 main()