library: add radosgw_user module

This adds radosgw_user ansible module for replacing the command module
usage with the radosgw-admin user command.

Signed-off-by: Dimitri Savineau <dsavinea@redhat.com>
pull/5911/head
Dimitri Savineau 2020-05-22 15:47:45 -04:00 committed by Guillaume Abrioux
parent 20718582da
commit 235c7e27cc
5 changed files with 678 additions and 70 deletions

View File

@ -0,0 +1,487 @@
# Copyright 2020, Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import absolute_import, division, print_function
__metaclass__ = type
ANSIBLE_METADATA = {
'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'
}
DOCUMENTATION = '''
---
module: radosgw_user
short_description: Manage RADOS Gateway User
version_added: "2.8"
description:
- Manage RADOS Gateway user(s) creation, deletion and updates.
options:
cluster:
description:
- The ceph cluster name.
required: false
default: ceph
name:
description:
- name of the RADOS Gateway user (uid).
required: true
state:
description:
If 'present' is used, the module creates a user if it doesn't
exist or update it if it already exists.
If 'absent' is used, the module will simply delete the user.
If 'info' is used, the module will return all details about the
existing user (json formatted).
required: false
choices: ['present', 'absent', 'info']
default: present
display_name:
description:
- set the display name of the user.
required: false
default: None
email:
description:
- set the email of the user.
required: false
default: None
access_key:
description:
- set the S3 access key of the user.
required: false
default: None
secret_key:
description:
- set the S3 secret key of the user.
required: false
default: None
realm:
description:
- set the realm of the user.
required: false
default: None
zonegroup:
description:
- set the zonegroup of the user.
required: false
default: None
zone:
description:
- set the zone of the user.
required: false
default: None
system:
description:
- set the system flag on the user.
required: false
default: false
admin:
description:
- set the admin flag on the user.
required: false
default: false
author:
- Dimitri Savineau <dsavinea@redhat.com>
'''
EXAMPLES = '''
- name: create a RADOS Gateway sytem user
radosgw_user:
name: foo
system: true
- name: modify a RADOS Gateway user
radosgw_user:
name: foo
email: foo@bar.io
access_key: LbwDPp2BBo2Sdlts89Um
secret_key: FavL6ueQWcWuWn0YXyQ3TnJ3mT3Uj5SGVHCUXC5K
state: present
- name: get a RADOS Gateway user information
radosgw_user:
name: foo
state: info
- name: delete a RADOS Gateway user
radosgw_user:
name: foo
state: absent
'''
RETURN = '''# '''
from ansible.module_utils.basic import AnsibleModule # noqa E402
import datetime # noqa E402
import json # noqa E402
import os # noqa E402
import stat # noqa E402
import time # noqa E402
def container_exec(binary, container_image):
'''
Build the docker CLI to run a command inside a container
'''
container_binary = os.getenv('CEPH_CONTAINER_BINARY')
command_exec = [container_binary,
'run',
'--rm',
'--net=host',
'-v', '/etc/ceph:/etc/ceph:z',
'-v', '/var/lib/ceph/:/var/lib/ceph/:z',
'-v', '/var/log/ceph/:/var/log/ceph/:z',
'--entrypoint=' + binary, container_image]
return command_exec
def is_containerized():
'''
Check if we are running on a containerized cluster
'''
if 'CEPH_CONTAINER_IMAGE' in os.environ:
container_image = os.getenv('CEPH_CONTAINER_IMAGE')
else:
container_image = None
return container_image
def pre_generate_radosgw_cmd(container_image=None):
'''
Generate radosgw-admin prefix comaand
'''
if container_image:
cmd = container_exec('radosgw-admin', container_image)
else:
cmd = ['radosgw-admin']
return cmd
def generate_radosgw_cmd(cluster, args, container_image=None):
'''
Generate 'radosgw' command line to execute
'''
cmd = pre_generate_radosgw_cmd(container_image=container_image)
base_cmd = [
'--cluster',
cluster,
'user'
]
cmd.extend(base_cmd + args)
return cmd
def exec_commands(module, cmd):
'''
Execute command(s)
'''
rc, out, err = module.run_command(cmd)
return rc, cmd, out, err
def create_user(module, container_image=None):
'''
Create a new user
'''
cluster = module.params.get('cluster')
name = module.params.get('name')
display_name = module.params.get('display_name')
if not display_name:
display_name = name
email = module.params.get('email', None)
access_key = module.params.get('access_key', None)
secret_key = module.params.get('secret_key', None)
realm = module.params.get('realm', None)
zonegroup = module.params.get('zonegroup', None)
zone = module.params.get('zone', None)
system = module.params.get('system', False)
admin = module.params.get('admin', False)
args = ['create', '--uid=' + name, '--display_name=' + display_name]
if email:
args.extend(['--email=' + email])
if access_key:
args.extend(['--access-key=' + access_key])
if secret_key:
args.extend(['--secret-key=' + secret_key])
if realm:
args.extend(['--rgw-realm=' + realm])
if zonegroup:
args.extend(['--rgw-zonegroup=' + zonegroup])
if zone:
args.extend(['--rgw-zone=' + zone])
if system:
args.append('--system')
if admin:
args.append('--admin')
cmd = generate_radosgw_cmd(cluster=cluster, args=args, container_image=container_image)
return cmd
def modify_user(module, container_image=None):
'''
Modify an existing user
'''
cluster = module.params.get('cluster')
name = module.params.get('name')
display_name = module.params.get('display_name')
if not display_name:
display_name = name
email = module.params.get('email', None)
access_key = module.params.get('access_key', None)
secret_key = module.params.get('secret_key', None)
realm = module.params.get('realm', None)
zonegroup = module.params.get('zonegroup', None)
zone = module.params.get('zone', None)
system = module.params.get('system', False)
admin = module.params.get('admin', False)
args = ['modify', '--uid=' + name]
if display_name:
args.extend(['--display_name=' + display_name])
if email:
args.extend(['--email=' + email])
if access_key:
args.extend(['--access-key=' + access_key])
if secret_key:
args.extend(['--secret-key=' + secret_key])
if realm:
args.extend(['--rgw-realm=' + realm])
if zonegroup:
args.extend(['--rgw-zonegroup=' + zonegroup])
if zone:
args.extend(['--rgw-zone=' + zone])
if system:
args.append('--system')
if admin:
args.append('--admin')
cmd = generate_radosgw_cmd(cluster=cluster, args=args, container_image=container_image)
return cmd
def get_user(module, container_image=None):
'''
Get existing user
'''
cluster = module.params.get('cluster')
name = module.params.get('name')
realm = module.params.get('realm', None)
zonegroup = module.params.get('zonegroup', None)
zone = module.params.get('zone', None)
args = ['info', '--uid=' + name, '--format=json']
if realm:
args.extend(['--rgw-realm=' + realm])
if zonegroup:
args.extend(['--rgw-zonegroup=' + zonegroup])
if zone:
args.extend(['--rgw-zone=' + zone])
cmd = generate_radosgw_cmd(cluster=cluster,
args=args,
container_image=container_image)
return cmd
def remove_user(module, container_image=None):
'''
Remove a user
'''
cluster = module.params.get('cluster')
name = module.params.get('name')
realm = module.params.get('realm', None)
zonegroup = module.params.get('zonegroup', None)
zone = module.params.get('zone', None)
args = ['rm', '--uid=' + name]
if realm:
args.extend(['--rgw-realm=' + realm])
if zonegroup:
args.extend(['--rgw-zonegroup=' + zonegroup])
if zone:
args.extend(['--rgw-zone=' + zone])
cmd = generate_radosgw_cmd(cluster=cluster, args=args, container_image=container_image)
return cmd
def exit_module(module, out, rc, cmd, err, startd, changed=False):
endd = datetime.datetime.now()
delta = endd - startd
result = dict(
cmd=cmd,
start=str(startd),
end=str(endd),
delta=str(delta),
rc=rc,
stdout=out.rstrip("\r\n"),
stderr=err.rstrip("\r\n"),
changed=changed,
)
module.exit_json(**result)
def run_module():
module_args = dict(
cluster=dict(type='str', required=False, default='ceph'),
name=dict(type='str', required=True),
state=dict(type='str', required=False, choices=['present', 'absent', 'info'], default='present'),
display_name=dict(type='str', required=False),
email=dict(type='str', required=False),
access_key=dict(type='str', required=False),
secret_key=dict(type='str', required=False),
realm=dict(type='str', required=False),
zonegroup=dict(type='str', required=False),
zone=dict(type='str', required=False),
system=dict(type='bool', required=False, default=False),
admin=dict(type='bool', required=False, default=False)
)
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)
# Gather module parameters in variables
name = module.params.get('name')
state = module.params.get('state')
display_name = module.params.get('display_name')
if not display_name:
display_name = name
email = module.params.get('email')
access_key = module.params.get('access_key')
secret_key = module.params.get('secret_key')
system = str(module.params.get('system')).lower()
admin = str(module.params.get('admin')).lower()
if module.check_mode:
module.exit_json(
changed=False,
stdout='',
stderr='',
rc=0,
start='',
end='',
delta='',
)
startd = datetime.datetime.now()
changed = False
# will return either the image name or None
container_image = is_containerized()
if state == "present":
rc, cmd, out, err = exec_commands(module, get_user(module, container_image=container_image))
if rc == 0:
user = json.loads(out)
current = {
'display_name': user['display_name'],
'system': user.get('system', 'false'),
'admin': user.get('admin', 'false')
}
asked = {
'display_name': display_name,
'system': system,
'admin': admin
}
if email:
current['email'] = user['email']
asked['email'] = email
if access_key:
current['access_key'] = user['keys'][0]['access_key']
asked['access_key'] = access_key
if secret_key:
current['secret_key'] = user['keys'][0]['secret_key']
asked['secret_key'] = secret_key
if current != asked:
rc, cmd, out, err = exec_commands(module, modify_user(module, container_image=container_image))
changed = True
else:
rc, cmd, out, err = exec_commands(module, create_user(module, container_image=container_image))
changed = True
elif state == "absent":
rc, cmd, out, err = exec_commands(module, get_user(module, container_image=container_image))
if rc == 0:
rc, cmd, out, err = exec_commands(module, remove_user(module, container_image=container_image))
changed = True
else:
rc = 0
out = "User {} doesn't exist".format(name)
elif state == "info":
rc, cmd, out, err = exec_commands(module, get_user(module, container_image=container_image))
exit_module(module=module, out=out, rc=rc, cmd=cmd, err=err, startd=startd, changed=changed)
def main():
run_module()
if __name__ == '__main__':
main()

View File

@ -182,29 +182,22 @@
when: groups.get(rgw_group_name, []) | length > 0 when: groups.get(rgw_group_name, []) | length > 0
run_once: true run_once: true
block: block:
- name: get radosgw system user
command: "timeout --foreground -s KILL 20 {{ container_exec_cmd }} radosgw-admin --cluster {{ cluster }} user info --uid={{ dashboard_rgw_api_user_id }}"
register: get_rgw_user
until: get_rgw_user.rc == 0
retries: 3
delegate_to: "{{ groups[mon_group_name][0] }}"
failed_when: false
changed_when: false
- name: create radosgw system user - name: create radosgw system user
command: "timeout --foreground -s KILL 20 {{ container_exec_cmd }} radosgw-admin --cluster {{ cluster }} user create --uid={{ dashboard_rgw_api_user_id }} --display-name='Ceph dashboard' --system" radosgw_user:
register: create_rgw_user name: "{{ dashboard_rgw_api_user_id }}"
until: create_rgw_user.rc == 0 cluster: "{{ cluster }}"
retries: 3 display_name: "Ceph dashboard"
system: true
delegate_to: "{{ groups[mon_group_name][0] }}" delegate_to: "{{ groups[mon_group_name][0] }}"
when: register: rgw_dashboard_user
- not rgw_multisite | bool or rgw_zonemaster | bool environment:
- get_rgw_user.rc == 22 CEPH_CONTAINER_IMAGE: "{{ ceph_docker_registry + '/' + ceph_docker_image + ':' + ceph_docker_image_tag if containerized_deployment | bool else None }}"
CEPH_CONTAINER_BINARY: "{{ container_binary }}"
- name: get the rgw access and secret keys - name: get the rgw access and secret keys
set_fact: set_fact:
rgw_access_key: "{{ (create_rgw_user.stdout | default(get_rgw_user.stdout) | from_json)['keys'][0]['access_key'] }}" rgw_access_key: "{{ (rgw_dashboard_user.stdout | from_json)['keys'][0]['access_key'] }}"
rgw_secret_key: "{{ (create_rgw_user.stdout | default(get_rgw_user.stdout) | from_json)['keys'][0]['secret_key'] }}" rgw_secret_key: "{{ (rgw_dashboard_user.stdout | from_json)['keys'][0]['secret_key'] }}"
- name: set the rgw user - name: set the rgw user
command: "{{ container_exec_cmd }} ceph --cluster {{ cluster }} dashboard set-rgw-api-user-id {{ dashboard_rgw_api_user_id }}" command: "{{ container_exec_cmd }} ceph --cluster {{ cluster }} dashboard set-rgw-api-user-id {{ dashboard_rgw_api_user_id }}"

View File

@ -4,47 +4,25 @@
container_exec_cmd_nfs: "{{ container_binary }} exec ceph-mon-{{ hostvars[groups[mon_group_name][0]]['ansible_hostname'] }}" container_exec_cmd_nfs: "{{ container_binary }} exec ceph-mon-{{ hostvars[groups[mon_group_name][0]]['ansible_hostname'] }}"
when: containerized_deployment | bool when: containerized_deployment | bool
- name: check if "{{ ceph_nfs_rgw_user }}" exists - name: create rgw nfs user "{{ ceph_nfs_rgw_user }}"
command: "{{ container_exec_cmd_nfs | default('') }} radosgw-admin --cluster {{ cluster }} user info --uid={{ ceph_nfs_rgw_user }}" radosgw_user:
name: "{{ ceph_nfs_rgw_user }}"
cluster: "{{ cluster }}"
display_name: "RGW NFS User"
access_key: "{{ ceph_nfs_rgw_access_key | default(omit) }}"
secret_key: "{{ ceph_nfs_rgw_secret_key | default(omit) }}"
run_once: true run_once: true
register: rgwuser_exists register: rgw_nfs_user
changed_when: false changed_when: false
failed_when: false
delegate_to: "{{ groups[mon_group_name][0] }}" delegate_to: "{{ groups[mon_group_name][0] }}"
when: nfs_obj_gw | bool when: nfs_obj_gw | bool
environment:
CEPH_CONTAINER_IMAGE: "{{ ceph_docker_registry + '/' + ceph_docker_image + ':' + ceph_docker_image_tag if containerized_deployment | bool else None }}"
CEPH_CONTAINER_BINARY: "{{ container_binary }}"
- name: create rgw nfs user "{{ ceph_nfs_rgw_user }}" - name: set_fact ceph_nfs_rgw_access_key and ceph_nfs_rgw_secret_key
command: "{{ container_exec_cmd_nfs | default('') }} radosgw-admin --cluster {{ cluster }} user create --uid={{ ceph_nfs_rgw_user }} --display-name='RGW NFS User'"
run_once: true
register: rgwuser
changed_when: false
delegate_to: "{{ groups[mon_group_name][0] }}"
when:
- nfs_obj_gw | bool
- rgwuser_exists.get('rc', 1) != 0
- name: modify rgw nfs user to use specific keys when those are defined
command: "{{ container_exec_cmd_nfs | default('') }} radosgw-admin --cluster {{ cluster }} user modify --uid={{ ceph_nfs_rgw_user }} --access-key={{ ceph_nfs_rgw_access_key }} --secret-key={{ ceph_nfs_rgw_secret_key }}"
delegate_to: "{{ groups[mon_group_name][0] }}"
when:
- nfs_obj_gw | bool
- ceph_nfs_rgw_access_key is defined
- ceph_nfs_rgw_secret_key is defined
- name: set_fact ceph_nfs_rgw_access_key
set_fact: set_fact:
ceph_nfs_rgw_access_key: "{{ (rgwuser.stdout | from_json)['keys'][0]['access_key'] if rgwuser_exists.get('rc', 1) != 0 else (rgwuser_exists.stdout | from_json)['keys'][0]['access_key'] }}" ceph_nfs_rgw_access_key: "{{ (rgw_nfs_user.stdout | from_json)['keys'][0]['access_key'] }}"
ceph_nfs_rgw_secret_key: "{{ (rgw_nfs_user.stdout | from_json)['keys'][0]['secret_key'] }}"
delegate_to: "{{ groups[mon_group_name][0] }}" delegate_to: "{{ groups[mon_group_name][0] }}"
when: when: nfs_obj_gw | bool
- nfs_obj_gw | bool
- ceph_nfs_rgw_access_key is not defined
- name: set_fact ceph_nfs_rgw_secret_key
set_fact:
ceph_nfs_rgw_secret_key: "{{ (rgwuser.stdout | from_json)['keys'][0]['secret_key'] if rgwuser_exists.get('rc', 1) != 0 else (rgwuser_exists.stdout | from_json)['keys'][0]['secret_key'] }}"
delegate_to: "{{ groups[mon_group_name][0] }}"
when:
- nfs_obj_gw | bool
- ceph_nfs_rgw_secret_key is not defined

View File

@ -8,21 +8,20 @@
- hostvars[item.host]['rgw_zonemaster'] | bool - hostvars[item.host]['rgw_zonemaster'] | bool
- hostvars[item.host]['rgw_zonegroupmaster'] | bool - hostvars[item.host]['rgw_zonegroupmaster'] | bool
- name: check if the realm system user already exists
command: "{{ container_exec_cmd }} radosgw-admin user info --cluster={{ cluster }} --rgw-realm={{ item.realm }} --rgw-zonegroup={{ item.zonegroup }} --rgw-zone={{ item.zone }} --uid={{ item.user }}"
delegate_to: "{{ groups[mon_group_name][0] }}"
register: usercheck
failed_when: False
changed_when: False
check_mode: no
run_once: True
loop: "{{ zone_users }}"
- name: create the zone user(s) - name: create the zone user(s)
command: "{{ container_exec_cmd }} radosgw-admin user create --cluster={{ cluster }} --rgw-realm={{ item.item.realm }} --rgw-zonegroup={{ item.item.zonegroup }} --rgw-zone={{ item.item.zone }} --uid={{ item.item.user }} --display-name='{{ item.item.display_name }}' --access-key={{ item.item.system_access_key }} --secret={{ item.item.system_secret_key }} --system" radosgw_user:
name: "{{ item.user }}"
cluster: "{{ cluster }}"
display_name: "{{ item.display_name }}"
access_key: "{{ item.system_access_key }}"
secret_key: "{{ item.system_secret_key }}"
realm: "{{ item.realm }}"
zonegroup: "{{ item.zonegroup }}"
zone: "{{ item.zone }}"
system: true
delegate_to: "{{ groups[mon_group_name][0] }}" delegate_to: "{{ groups[mon_group_name][0] }}"
run_once: true run_once: true
loop: "{{ usercheck.results }}" loop: "{{ zone_users }}"
when: environment:
- zone_users is defined CEPH_CONTAINER_IMAGE: "{{ ceph_docker_registry + '/' + ceph_docker_image + ':' + ceph_docker_image_tag if containerized_deployment | bool else None }}"
- "'could not fetch user info: no user info saved' in item.stderr" CEPH_CONTAINER_BINARY: "{{ container_binary }}"

View File

@ -0,0 +1,151 @@
import os
import sys
from mock.mock import patch, MagicMock
import pytest
sys.path.append('./library')
import radosgw_user # noqa: E402
fake_binary = 'radosgw-admin'
fake_cluster = 'ceph'
fake_container_binary = 'podman'
fake_container_image = 'docker.io/ceph/daemon:latest'
fake_container_cmd = [
fake_container_binary,
'run',
'--rm',
'--net=host',
'-v', '/etc/ceph:/etc/ceph:z',
'-v', '/var/lib/ceph/:/var/lib/ceph/:z',
'-v', '/var/log/ceph/:/var/log/ceph/:z',
'--entrypoint=' + fake_binary,
fake_container_image
]
fake_user = 'foo'
fake_realm = 'canada'
fake_zonegroup = 'quebec'
fake_zone = 'montreal'
fake_params = {'cluster': fake_cluster,
'name': fake_user,
'display_name': fake_user,
'email': fake_user,
'access_key': 'PC7NPg87QWhOzXTkXIhX',
'secret_key': 'jV64v39lVTjEx1ZJN6ocopnhvwMp1mXCD4kzBiPz',
'realm': fake_realm,
'zonegroup': fake_zonegroup,
'zone': fake_zone,
'system': True,
'admin': True}
class TestRadosgwUserModule(object):
@patch.dict(os.environ, {'CEPH_CONTAINER_BINARY': fake_container_binary})
def test_container_exec(self):
cmd = radosgw_user.container_exec(fake_binary, fake_container_image)
assert cmd == fake_container_cmd
def test_not_is_containerized(self):
assert radosgw_user.is_containerized() is None
@patch.dict(os.environ, {'CEPH_CONTAINER_IMAGE': fake_container_image})
def test_is_containerized(self):
assert radosgw_user.is_containerized() == fake_container_image
@pytest.mark.parametrize('image', [None, fake_container_image])
@patch.dict(os.environ, {'CEPH_CONTAINER_BINARY': fake_container_binary})
def test_pre_generate_radosgw_cmd(self, image):
if image:
expected_cmd = fake_container_cmd
else:
expected_cmd = [fake_binary]
assert radosgw_user.pre_generate_radosgw_cmd(image) == expected_cmd
@pytest.mark.parametrize('image', [None, fake_container_image])
@patch.dict(os.environ, {'CEPH_CONTAINER_BINARY': fake_container_binary})
def test_generate_radosgw_cmd(self, image):
if image:
expected_cmd = fake_container_cmd
else:
expected_cmd = [fake_binary]
expected_cmd.extend([
'--cluster',
fake_cluster,
'user'
])
assert radosgw_user.generate_radosgw_cmd(fake_cluster, [], image) == expected_cmd
def test_create_user(self):
fake_module = MagicMock()
fake_module.params = fake_params
expected_cmd = [
fake_binary,
'--cluster', fake_cluster,
'user', 'create',
'--uid=' + fake_user,
'--display_name=' + fake_user,
'--email=' + fake_user,
'--access-key=PC7NPg87QWhOzXTkXIhX',
'--secret-key=jV64v39lVTjEx1ZJN6ocopnhvwMp1mXCD4kzBiPz',
'--rgw-realm=' + fake_realm,
'--rgw-zonegroup=' + fake_zonegroup,
'--rgw-zone=' + fake_zone,
'--system',
'--admin'
]
assert radosgw_user.create_user(fake_module) == expected_cmd
def test_modify_user(self):
fake_module = MagicMock()
fake_module.params = fake_params
expected_cmd = [
fake_binary,
'--cluster', fake_cluster,
'user', 'modify',
'--uid=' + fake_user,
'--display_name=' + fake_user,
'--email=' + fake_user,
'--access-key=PC7NPg87QWhOzXTkXIhX',
'--secret-key=jV64v39lVTjEx1ZJN6ocopnhvwMp1mXCD4kzBiPz',
'--rgw-realm=' + fake_realm,
'--rgw-zonegroup=' + fake_zonegroup,
'--rgw-zone=' + fake_zone,
'--system',
'--admin'
]
assert radosgw_user.modify_user(fake_module) == expected_cmd
def test_get_user(self):
fake_module = MagicMock()
fake_module.params = fake_params
expected_cmd = [
fake_binary,
'--cluster', fake_cluster,
'user', 'info',
'--uid=' + fake_user,
'--format=json',
'--rgw-realm=' + fake_realm,
'--rgw-zonegroup=' + fake_zonegroup,
'--rgw-zone=' + fake_zone
]
assert radosgw_user.get_user(fake_module) == expected_cmd
def test_remove_user(self):
fake_module = MagicMock()
fake_module.params = fake_params
expected_cmd = [
fake_binary,
'--cluster', fake_cluster,
'user', 'rm',
'--uid=' + fake_user,
'--rgw-realm=' + fake_realm,
'--rgw-zonegroup=' + fake_zonegroup,
'--rgw-zone=' + fake_zone
]
assert radosgw_user.remove_user(fake_module) == expected_cmd