
- 3 Controller - 3 Compute가 일반적인 OpenStack의 구성으로 이는 다중화를 위해서이다
- SAN: 저장소용 백엔드 서버 / SAF: 분산 스토리지
- 결국 OpenStack과 같은 IaaS, PaaS 클라우드 기술은 인스턴스 (컴퓨팅 리소스)를 제공하기 위한 것이며, 따라서 가장 메인이 되는 프로젝트는 Nova, Neutron이 된다

python-openstackcli를 통해 다양한 서비스 관리 및 조작 가능kubectl와 유사한 느낌nova list -> openstack server list와 같이 활용 가능하도록 함Barbican에 대한 명령 집합을 추가하는 것https://opendev.org/openstack/python-openstackclient
class ListServer(command.Lister):
_description = _("List servers")
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
parser.add_argument(
'--reservation-id',
metavar='<reservation-id>',
help=_('Only return instances that match the reservation'),
)
parser.add_argument(
'--ip',
metavar='<ip-address-regex>',
help=_('Regular expression to match IP addresses'),
)
parser.add_argument(
'--ip6',
metavar='<ip-address-regex>',
help=_(
'Regular expression to match IPv6 addresses. Note '
'that this option only applies for non-admin users '
'when using ``--os-compute-api-version`` 2.5 or greater.'
),
)
...
parser.add_argument(
'--status',
metavar='<status>',
choices=(
'ACTIVE',
'BUILD',
'DELETED',
'ERROR',
'HARD_REBOOT',
'MIGRATING',
'PASSWORD',
'PAUSED',
'REBOOT',
'REBUILD',
'RESCUE',
'RESIZE',
'REVERT_RESIZE',
'SHELVED',
'SHELVED_OFFLOADED',
'SHUTOFF',
'SOFT_DELETED',
'SUSPENDED',
'VERIFY_RESIZE',
),
help=_('Search by server status'),
)
...
openstack server list를 쳤을때 실행되는 클래스이다openstackcli 프로젝트의 다른 내용들은 모두 포맷이 동일하다get_parser: Option들을 파싱해서 처리하는 메서드take_action: get_parser를 통해 만들어진 데이터를 통해 실제 동작을 수행하는 메서드
--debug 옵션은 실행한 CLI가 결과값을 보여주기까지 어떤 API를 호출하고 결과값을 가져오는지의 과정을 보여주는 옵션이다.
위 옵션을 통해서 openstackcli 명령을 분석할 수 있다.
START with options: server list --debug
options: Namespace(verbose_level=3, log_file=None, deferred_help=False, debug=True, cloud='', region_name='', cacert=None, cert='', key='', verify=None, insecure=None, default_domain='default' ...
Auth plugin password selected
...
{
"endpoints":[
{
"id":"5882de4449b24b73a2b11835df9db71d",
"interface":"public",
"region_id":"RegionOne",
"url":"http://controller:9292",
"region":"RegionOne"
},
{
"id":"bc020a0587104bff898dc0dcd41ca0ed",
"interface":"admin",
"region_id":"RegionOne",
"url":"http://controller:9292",
"region":"RegionOne"
},
{
"id":"e18153d6e3924d268112ea67d1544f42",
"interface":"internal",
"region_id":"RegionOne",
"url":"http://controller:9292",
"region":"RegionOne"
}
],
"id":"6048f1fd11cb4dcda5de41d1251a0296",
"type":"image",
"name":"glance"
},
...
take_action에서 촤종 출력 결과인 표를 만들어주는 형태이다Keystone이 각 엔드포인트들을 받아와주고, CLI가 이를 활용해 다른 서비스들을 호출해준다openstack endpoint set ...을 통해 변경한다REQ: curl -g -i -X GET http://controller:8774/v2.1/servers/detail -H "Accept: application/json" -H "User-Agent: python-novaclient" -H "X-Auth-Token: {SHA256}11..." -H "X-OpenStack-Nova-API-Version: 2.1"
Starting new HTTP connection (1): controller:8774
...
RESP BODY:
{
"servers":[
{
"id":"b4ce6073-3d50-4c06-b284-c6e69e1945b8",
"name":"test-instance",
"status":"ACTIVE",
"tenant_id":"0636a6ee7b2242e7ba9faf5e7d1daed4",
"user_id":"12529d0a6bfa4cb1894172f0fe354de9",
"metadata":{
},
"hostId":"ccbe882631a17eb2fcc5b981a33cd66fcf719bee59d1c6c96e741582",
"image":{
"id":"73c6e7ee-3b6c-40dd-bdc5-96058e183faa",
"links":[
{
"rel":"bookmark",
"href":"http://controller:8774/images/73c6e7ee-3b6c-40dd-bdc5-96058e183faa"
}
]
},
"flavor":{
"id":"c4ee93fc-2e98-4473-aab7-ff75214be0c0",
"links":[
{
"rel":"bookmark",
"href":"http://controller:8774/flavors/c4ee93fc-2e98-4473-aab7-ff75214be0c0"
}
]
},
"created":"2025-07-10T13:20:30Z",
"updated":"2025-07-21T09:06:11Z",
"addresses":{
"private-net":[
{
"version":4,
"addr":"10.0.0.77",
"OS-EXT-IPS:type":"fixed",
"OS-EXT-IPS-MAC:mac_addr":"fa:16:3e:20:f8:0d"
}
],
"ext-net":[
{
"version":4,
"addr":"192.168.100.73",
"OS-EXT-IPS:type":"fixed",
"OS-EXT-IPS-MAC:mac_addr":"fa:16:3e:64:23:06"
}
]
},
"accessIPv4":"",
"accessIPv6":"",
"links":[
{
"rel":"self",
"href":"http://controller:8774/v2.1/servers/b4ce6073-3d50-4c06-b284-c6e69e1945b8"
},
{
"rel":"bookmark",
"href":"http://controller:8774/servers/b4ce6073-3d50-4c06-b284-c6e69e1945b8"
}
],
"OS-DCF:diskConfig":"MANUAL",
"progress":0,
"OS-EXT-AZ:availability_zone":"nova",
"config_drive":"",
"key_name":"mykey",
"OS-SRV-USG:launched_at":"2025-07-10T13:20:36.000000",
"OS-SRV-USG:terminated_at":null,
"OS-EXT-SRV-ATTR:host":"compute",
"OS-EXT-SRV-ATTR:instance_name":"instance-0000001c",
"OS-EXT-SRV-ATTR:hypervisor_hostname":"compute",
"OS-EXT-STS:task_state":null,
"OS-EXT-STS:vm_state":"active",
"OS-EXT-STS:power_state":1,
"os-extended-volumes:volumes_attached":[
],
"security_groups":[
{
"name":"default"
},
{
"name":"default"
}
]
}
]
}
python-openstackcli는 curl을 통해 Token을 받아온 뒤 이를 통해 nova와 통신하는 예시는 위와 같다Keystone API
https://docs.openstack.org/api-ref/identity/v3/index.html
Glance API
https://docs.openstack.org/api-ref/image/v2/index.html
위와 같은 명세서를 기반으로 Req에 대한 Res를 받아볼 수 있다.

https://docs.openstack.org/openstacksdk
https://opendev.org/openstack/openstacksdk
from openstack import connection
# 인증 정보를 담아 Connection 객체 생성
conn = connection.Connection(
region_name='RegionOne',
auth={
'auth_url': 'http://<엔드포인트>/identity',
'username': 'admin',
'password': '<PW>',
'project_id': '67924c35cf3c4362b6f49e7be59492de',
'user_domain_id': 'default'
},
compute_api_version='2',
)
# openstack/compute/v2/_proxy.py
from openstack.compute.v2 import server as _server
...
def servers(self, details=True, **query):
if all_projects:
query['all_projects'] = True
base_path = '/servers/detail' if details else None
return self._list(_server.Server, base_path=base_path, **query)
conn.compute.servers()처럼 사용하기 쉬운 메소드를 제공# openstack/compute/v2/server.py
from openstack import resource
...
class Server(resource.Resource, ...):
resource_key = 'server'
resources_key = 'servers'
base_path = '/servers' # API 기본 경로
allow_list = True # 목록 조회(GET) 허용
...
사용자 → conn.compute.servers() 호출 → Connection (연결) → Proxy (작업 지시) → Resource (설계도 참조) → OpenStack API
OpenStack SDK는 위와 같은 명확한 흐름으로 동작하며 이처럼 잘 정의된 계층 구조 덕분에 개발자는 복잡한 API 통신의 내부 과정을 신경 쓸 필요 없이, 필요한 결과물을 얻을 수 있다.
![]()
openstack server list의 경우 위와 같이 한정된 값만을 출력해주는데 해당 결과값에project name,user name을 포함시켜서 출력
# /openstackclient/compute/v2/server.py
class ListServer(command.Lister):
_description = _("List servers")
...
def take_action(self, parsed_args):
...
try:
projects_map = {p.id: p.name for p in identity_client.projects.list()}
users_map = {u.id: u.name for u in identity_client.users.list()}
except Exception as e:
LOG.warning("Could not pre-fetch projects or users: %s", e)
projects_map = {}
users_map = {}
search_opts = {
'reservation_id': parsed_args.reservation_id,
...
Project와 User 이름을 미리 조회하여 맵으로 만듭니다.
이때 openstacksdk에서는 리소스 목록을 가져올 때 .list()를 명시적으로 호출해야 합니다.
따라서 리소스 목록을 가져올 때 identity_client.projects.list()와 같이 사용해야 합니다.
# /openstackclient/compute/v2/server.py
class ListServer(command.Lister):
_description = _("List servers")
...
def take_action(self, parsed_args):
...
columns: tuple[str, ...] = (
'id',
'name',
'status',
)
column_headers: tuple[str, ...] = (
'ID',
'Name',
'Status',
)
columns += ('project_name', 'user_name')
column_headers += ('Project Name', 'User Name')
...
columns와 column_headers 튜플에 Project Name과 User Name을 추가합니다.
# /openstackclient/compute/v2/server.py
class ListServer(command.Lister):
_description = _("List servers")
...
def take_action(self, parsed_args):
...
data = list(compute_client.servers(**search_opts))
for s in data:
s.project_name = projects_map.get(s.project_id, s.project_id)
s.user_name = users_map.get(s.user_id, s.user_id)
images = {}
flavors = {}
...
compute_client.servers(**search_opts)를 통해 서버 목록인 data를 가져온 후, 이 목록을 순회하며 각 서버 객체인 s에 project_name과 user_name 속성을 동적으로 추가합니다.
// launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Python 디버거: 인수가 있는 현재 파일",
"type": "debugpy",
"request": "launch",
"program": "python-openstackclient/openstackclient/shell.py",
"console": "integratedTerminal",
"args": [
"server",
"list"
],
"env": {
"OS_PROJECT_NAME": "admin",
"OS_TENANT_NAME": "admin",
"OS_USERNAME": "admin",
"OS_PASSWORD": "<PW>",
"OS_REGION_NAME": "RegionOne",
"OS_IDENTITY_API_VERSION": "3",
"OS_AUTH_TYPE": "password",
"OS_AUTH_URL": "http://<ENDPOINT>/identity",
"OS_USER_DOMAIN_ID": "default",
"OS_PROJECT_DOMAIN_ID": "default",
"OS_VOLUME_API_VERSION": "3"
}
}
]
}
위와 같이 launch.json의 "program": "python-openstackclient/openstackclient/shell.py"와 args를 설정하고 실행시키면
$ OCA-OpenStack\\Scripts\\python.exe c:\\Users\\lenovo\\.cursor\\extensions\\ms-python.debugpy-2024.6.0-win32-x64\\bundled\\libs\\debugpy\\adapter/../..\\debugpy\\launcher 51322 -- python-openstackclient/openstackclient/shell.py server list
+--------------------------------------+-----------------+--------+--------------+-----------+----------------------------------------+--------------------------+-----------+
| ID | Name | Status | Project Name | User Name | Networks | Image | Flavor |
+--------------------------------------+-----------------+--------+--------------+-----------+----------------------------------------+--------------------------+-----------+
| 182eef16-2d78-41c0-a962-c2e75cfc0699 | cirros-instance | ACTIVE | admin | admin | shared=192.168.100.59, 192.168.233.187 | N/A (booted from volume) | cirros256 |
+--------------------------------------+-----------------+--------+--------------+-----------+----------------------------------------+--------------------------+-----------+
위와 같이 Project Name괴 User Name이 함께 출력되는 것을 확인할 수 있습니다.
openstack server list를 입력했을 때
take_action()함수를 찾아가는 과정 분석해보기
# shell.py
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
return OpenStackShell().run(argv)
if __name__ == "__main__":
sys.exit(main())
args: ["server", "list"]가 shell.py 스크립트에 Command Line 인자로 전달되며 코드 내에서는 sys.argv를 통해 이 값을 받게 됩니다.
이때 main() 메서드의 역할은 ['server', 'list']를 받아 OpenStackShell 클래스의 run() 메서드에 그대로 넘겨주는 것입니다.
# shell.py
class OpenStackShell(shell.OpenStackShell):
...
def initialize_app(self, argv):
super().initialize_app(argv)
# Re-create the client_manager with our subclass
self.client_manager = clientmanager.ClientManager(
cli_options=self.cloud,
api_version=self.api_version,
pw_func=shell.prompt_for_password,
)
setup.cfg에 정의된 진입점으로, OpenStackShell 앱을 생성하고 실행합니다.
# osc-lib/osc_lib/shell.py
class OpenStackShell(app.App):
...
def run(self, argv: list[str]) -> int:
ret_val = 1
self.command_options = argv
try:
ret_val = super().run(argv)
return ret_val
...
openstackclient의 OpenStackShell은 위 클래스를 상속받아 실제 run() 로직의 대부분은 이 부모 클래스와, 이 클래스가 다시 호출하는 cliff.app.App에 의해 처리됩니다.
# Lib/site-packages/cliff/commandmanager.py
class CommanManager:
...
def find_command(
self, argv: list[str]
...
if found:
cmd_ep = self.commands[found]
if hasattr(cmd_ep, 'resolve'):
cmd_factory = cmd_ep.resolve()
else:
# NOTE(dhellmann): Some fake classes don't take
# require as an argument. Yay?
arg_spec = inspect.getfullargspec(cmd_ep.load)
if 'require' in arg_spec[0]:
cmd_factory = cmd_ep.load(require=False)
else:
cmd_factory = cmd_ep.load()
return (cmd_factory, return_name, search_args)
...
Cliff는 Python으로 커맨드라인 프로그램(CLI)을 개발하기 위한 프레임워크입니다. OpenStackClient와 같이 복잡한 다단계 명령어를 가진 애플리케이션을 만드는 데 주로 사용됩니다.
run_subcommand 메서드 내부에서 cliff.commandmanager.CommandManager의 인스턴스인 self.command_manager를 사용하여 find_command를 호출하고, 인자로 넘어온 ['server', 'list']에 해당하는 ListServer 클래스를 찾아냅니다.
run_subcommand 메서드가 find_command를 통해 찾아낸 cmd_factory인 ListServer 클래스를 사용하여 cmd = cmd_factory()를 통해 인스턴스를 생성합니다.
# Lib/site-packages/cliff/command.py
class Command(metaclass=abc.ABCMeta):
def run(self, parsed_args: argparse.Namespace) -> int:
"""Invoked by the application when the command is run.
Developers implementing commands should override
:meth:`take_action`.
Developers creating new command base classes (such as
:class:`Lister` and :class:`ShowOne`) should override this
method to wrap :meth:`take_action`.
Return the value returned by :meth:`take_action` or 0.
"""
parsed_args = self._run_before_hooks(parsed_args)
return_code = self.take_action(parsed_args) or 0
return_code = self._run_after_hooks(parsed_args, return_code)
return return_code
ListServer를 포함한 모든 명령어 클래스의 부모 클래스인 /cliff/command.py의 run() 메서드가 self.take_action(parsed_args)를 호출함으로써, 실제 로직이 담긴 메서드로 연결됩니다.
# python-openstackclient/openstackclient/compute/v2/server.py
class ListServer(command.Lister):
_description = _("List servers")
...
def take_action(self, parsed_args):
...
따라서 최종적으로 python-openstackclient/openstackclient/compute/v2/server.py의 ListServer 클래스의 take_action 메서드가 호출됩니다.

Given: 테스트 대상 행위를 일으키기 위한 초기 셋업When: 테스트 대상 행위를 발생시키는 이벤트 기술Then 이벤트 발생 시 기대하는 결과값들을 확인
Stub, Mock 등 사용unittest, pytest (pytest를 주로 쓴다)
Keystone에 키 검증을 요청한다
Fernet Token이라는 토큰 구조를 사용한다Primary: 실제 암호화/복호화 시 사용, 단 하나, 가장 높은 인덱스Secondary: 보조키, 토큰을 해독만 할 수 있다Staged: 준비키, 하나만, 반드시 있어야, 해독만할 수 있다, Primary Key가 된 적이 없고 저장소에서 0이란 이름을 가진다
nova-api: End User의 Compute API 호출을 허용하고 응답nova-api-metadata: 인스턴스 메타데이터 요청 수락nova-compute: 하이퍼바이저 API를 통해 VM 인스턴스를 생성하고 종료하는 데몬nova-scheduler: Queue에서 VM 인ㄴ스턴스의 Request를 가져와 가상 시스템이 실행되는 Compute 서버 Node를 결정nova-conductor: nova-compute 서비스와 데이터베이스 간에 상호작용 관리nova-novncproxy: VNC 연결을 통해 실행 중인 인스턴스에 액세스하는 프록시 제공
2주차에 진행한
server list확장 코드에서tox를 통해 unit test를 실행시키면 Test Failed가 발생합니다.
openstackclient.tests.unit.compute경로에 있는server list에 대한 unit test를 수정하여 tox 실행시 모든 unit test에서 성공하도록 해주세요
제출 내용 1: 수정된 unit test code (스크린샷 또는 실제 코드)
제출 내용 2: 아래의 예시와 같이 tox를 통해 모든 unit test 통과한 화면
$ tox
...
py3: commands[0]> stestr run
Traceback (most recent call last):
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "C:\Users\lenovo\Git\2025-OSCA\OCA-OpenStack\python-openstackclient\.tox\py3\Lib\site-packages\stestr\subunit_runner\run.py", line 88, in <module>
main()
File "C:\Users\lenovo\Git\2025-OSCA\OCA-OpenStack\python-openstackclient\.tox\py3\Lib\site-packages\stestr\subunit_runner\run.py", line 82, in main
program.TestProgram(
File "C:\Users\lenovo\Git\2025-OSCA\OCA-OpenStack\python-openstackclient\.tox\py3\Lib\site-packages\stestr\subunit_runner\program.py", line 179, in __init__
self.parseArgs(argv)
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\unittest\main.py", line 130, in parseArgs
self._do_discovery(argv[2:])
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\unittest\main.py", line 253, in _do_discovery
self.createTests(from_discovery=True, Loader=Loader)
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\unittest\main.py", line 160, in createTests
self.test = loader.discover(self.start, self.pattern, self.top)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\unittest\loader.py", line 308, in discover
raise ImportError('Start directory is not importable: %r' % start_dir)
ImportError: Start directory is not importable: '${OS_TEST_PATH:-./openstackclient/tests/unit}'
tox 명령어를 실행했을때 위와 같은 오류가 발생하였습니다.
이는 tox가 테스트를 실행할 때, 테스트 파일의 위치를 잘못 읽어오면서 발생한 문제로
# python-openstackclient/.stestr.conf
[DEFAULT]
# test_path=${OS_TEST_PATH:-./openstackclient/tests/unit}
test_path=./openstackclient/tests/unit
top_dir=./
group_regex=([^\.]+\.)+
.stestr.conf파일은stestr의 핵심 설정 파일이며stestr는OpenStack프로젝트에서 널리 사용되는 Test Runner 입니다.
stestr 설정 파일은 셸이 아니므로, ${...} 같은 변수 확장 문법을 해석하지 못합니다.
따라서 test_path의 값을 openstackclient/tests/unit이라는 실제 경로가 아닌, ${OS_TEST_PATH:-./openstackclient/tests/unit} 이라는 문자열 그대로 인식하여 존재하지 않는 디렉토리를 찾으려고 시도하기 때문에 계속해서 ImportError가 발생하였다고 판단하였습니다.
따라서 .stestr.conf 파일에서 변수 확장 문법을 제거하고 실제 경로만 남겨 문제를 해결하였습니다.
$ tox
...
openstackclient.tests.unit.compute.v2.test_server.TestServerListV273.test_server_list_with_unlocked_v273
--------------------------------------------------------------------------------------------------------
Captured traceback:
~~~~~~~~~~~~~~~~~~~
Traceback (most recent call last):
File "C:\Users\lenovo\Git\2025-OSCA\OCA-OpenStack\python-openstackclient\openstackclient\tests\unit\compute\v2\test_server.py", line 5440, in test_server_list_with_unlocked_v273
self.assertCountEqual(self.columns, columns)
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\unittest\case.py", line 1216, in assertCountEqual
self.fail(msg)
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\unittest\case.py", line 715, in fail
raise self.failureException(msg)
AssertionError: Element counts were not equal:
First has 0, Second has 1: 'Project Name'
First has 0, Second has 1: 'User Name'
Captured pythonlogging:
~~~~~~~~~~~~~~~~~~~~~~~
Could not pre-fetch projects or users: 'Mock' object is not iterable
또한 이후 정상적으로 tox를 실행시켰을때 강의에서 안내주신 내용처럼 위와 같은 테스트 실패가 발생하였습니다.
# python-openstackclient/openstackclient/tests/unit/compute/v2/test_server.py
from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes
이후 identity_fakes를 통해 Project와 User에 대한 Mock 데이터를 임의로 만들어주기 위해 위와 같이 패키지를 import하였습니다.
class _TestServerList(TestServer):
# Columns to be listed up.
columns = (
'ID',
'Name',
'Status',
# columns 튜플에 Project Name과 User Name 추가
'Project Name',
'User Name',
'Networks',
'Image',
'Flavor',
)
columns_long = (
'ID',
'Name',
'Status',
# columns_long 튜플에 Project Name과 User Name 추가
'Project Name',
'User Name',
'Flavor ID',
'Task State',
'Power State',
'Networks',
'Image Name',
'Image ID',
'Flavor Name',
'Flavor ID',
'Availability Zone',
'Pinned Availability Zone',
'Host',
'Properties',
'Scheduler Hints',
)
columns_all_projects = (
'ID',
'Name',
'Status',
# columns_all_projects 튜플에 Project Name과 User Name 추가
'Project Name',
'User Name',
'Networks',
'Image',
'Flavor',
'Project ID',
)
def setUp(self):
super().setUp()
# Default params of the core function of the command in the case of no
# commandline option specified.
self.kwargs = {
'reservation_id': None,
'ip': None,
'ip6': None,
'name': None,
'status': None,
'flavor': None,
'image': None,
'compute_host': None,
'project_id': None,
'all_projects': False,
'user_id': None,
'deleted': False,
'changes-since': None,
'changes-before': None,
}
# 테스트를 위한 project, user id 설정
self.project_id = 'project-id-for-testing'
self.user_id = 'user-id-for-testing'
# The fake servers' attributes. Use the original attributes names in
# nova, not the ones printed by "server list" command.
self.attrs = {
# Mock 서버 생성 시의 속성 명시적으로 추가
'project_id': self.project_id,
'user_id': self.user_id,
'status': 'ACTIVE',
'OS-EXT-STS:task_state': 'None',
'OS-EXT-STS:power_state': 0x01, # Running
'networks': {'public': ['10.20.30.40', '2001:db8::5']},
'OS-EXT-AZ:availability_zone': 'availability-zone-xxx',
'OS-EXT-SRV-ATTR:host': 'host-name-xxx',
'Metadata': format_columns.DictColumn({}),
}
self.image = image_fakes.create_one_image()
self.image_client.find_image.return_value = self.image
self.image_client.get_image.return_value = self.image
self.flavor = compute_fakes.create_one_flavor()
self.compute_client.find_flavor.return_value = self.flavor
self.attrs['flavor'] = {'original_name': self.flavor.name}
# The servers to be listed.
self.servers = self.setup_sdk_servers_mock(3)
self.compute_client.servers.return_value = self.servers
# 정의한 ID와 동일한 ID를 가진 Fake 프로젝트/사용자 객체를 생성
self.project = identity_fakes.FakeProject.create_one_project(
attrs={'id': self.project_id}
)
self.user = identity_fakes.FakeUser.create_one_user(
attrs={'id': self.user_id}
)
이후 위와 같이 테스트의 기본 설정을 담당하는 _TestServerList 클래스에서 예상 컬럼 목록을 수정합니다.
또한 임의로 정의한 ID와 동일한 ID를 가진 Fake 프로젝트/사용자 객체를 생성하도록 setUp()을 수정합니다.
# python-openstackclient/openstackclient/tests/unit/compute/v2/test_server.py
class TestServerList(_TestServerList):
def test_server_list_long_option(self):
self.data = tuple(
(
s.id,
s.name,
s.status,
# project name과 user name 컬럼 추가
# 이때 추가하는 순서에 주의해야 함
self.project.name,
self.user.name,
getattr(s, 'task_state'),
server.PowerStateColumn(getattr(s, 'power_state')),
server.AddressesColumn(s.addresses),
# Image will be an empty string if boot-from-volume
self.image.name if s.image else server.IMAGE_STRING_FOR_BFV,
s.image['id'] if s.image else server.IMAGE_STRING_FOR_BFV,
self.flavor.name,
s.flavor['id'],
...
또한 실제로 테스트를 수행하는 test_server_list_long_option() 등의 메소드에 대해 self.data 튜플에 컬럼들을 모두 추가해줍니다.
이때, 추가하는 순서에 주의해야 합니다.
# python-openstackclient/openstackclient/tests/unit/compute/v2/test_server.py
class TestServerListV273(_TestServerList):
# Columns to be listed up.
columns = (
'ID',
'Name',
'Status',
'Project Name',
'User Name',
'Networks',
'Image',
'Flavor',
)
columns_long = (
'ID',
'Name',
'Status',
'Project Name',
'User Name',
'Task State',
'Power State',
'Networks',
'Image Name',
'Image ID',
'Flavor',
'Availability Zone',
'Pinned Availability Zone',
'Host',
'Properties',
'Scheduler Hints',
)
def setUp(self):
super().setUp()
# The fake servers' attributes. Use the original attributes names in
# nova, not the ones printed by "server list" command.
self.attrs['flavor'] = {
'vcpus': self.flavor.vcpus,
'ram': self.flavor.ram,
'disk': self.flavor.disk,
'ephemeral': self.flavor.ephemeral,
'swap': self.flavor.swap,
'original_name': self.flavor.name,
'extra_specs': self.flavor.extra_specs,
}
# The servers to be listed.
self.servers = self.setup_sdk_servers_mock(3)
self.compute_client.servers.return_value = self.servers
Image = collections.namedtuple('Image', 'id name')
self.image_client.images.return_value = [
Image(id=s.image['id'], name=self.image.name)
# Image will be an empty string if boot-from-volume
for s in self.servers
if s.image
]
# The flavor information is embedded, so now reason for this to be
# called
self.compute_client.flavors = mock.NonCallableMock()
self.data = tuple(
(
s.id,
s.name,
s.status,
# 순서에 맞게 project name과 user name 추가
self.project.name,
self.user.name,
server.AddressesColumn(s.addresses),
# Image will be an empty string if boot-from-volume
self.image.name if s.image else server.IMAGE_STRING_FOR_BFV,
self.flavor.name,
)
for s in self.servers
)
TestServerListV273 클래스에 대해서도 동일하게 columns의 정보를 추가합니다.
해당 클래스의 경우 이미 _TestServerList를 상속받고 있기 때문에 새로운 ID와 Mock Identity를 생성하지는 않고, self.data만 수정합니다.
# python-openstackclient/openstackclient/tests/unit/compute/v2/test_server.py
class TestServerListV273(_TestServerList):
def test_server_list_v269_with_partial_constructs(self):
self.set_compute_api_version('2.69')
arglist = []
verifylist = []
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
# include "partial results" from non-responsive part of
# infrastructure.
server_dict = {
"id": "server-id-95a56bfc4xxxxxx28d7e418bfd97813a",
"status": "UNKNOWN",
"tenant_id": "6f70656e737461636b20342065766572",
"created": "2018-12-03T21:06:18Z",
"links": [
{"href": "http://fake/v2.1/", "rel": "self"},
{"href": "http://fake", "rel": "bookmark"},
],
"networks": {},
}
fake_server = _server.Server(**server_dict)
self.servers.append(fake_server)
columns, data = self.cmd.take_action(parsed_args)
# get the first three servers out since our interest is in the partial
# server.
next(data)
next(data)
next(data)
partial_server = next(data)
expected_row = (
'server-id-95a56bfc4xxxxxx28d7e418bfd97813a',
None,
'UNKNOWN',
# self.project.name 대신, 위 server_dict에 하드코딩된 값 사용
'6f70656e737461636b20342065766572',
# self.user.name 대신, user_id가 없으므로 None 기대
None,
server.AddressesColumn(None),
'',
'',
)
self.assertEqual(expected_row, partial_server)
test_server_list_v269_with_partial_constructs() 메소드의 경우 하드코딩된 테스트 데이터를 사용하기 때문에 이에 맞게 tox의 출력결과를 기반으로 expected_row를 수정해줍니다.
==============================
Failed 1 tests - output below:
==============================
openstackclient.tests.unit.compute.v2.test_server.TestServerCreate.test_server_create_with_block_device_from_file
-----------------------------------------------------------------------------------------------------------------
Captured traceback:
~~~~~~~~~~~~~~~~~~~
Traceback (most recent call last):
File "C:\Users\lenovo\Git\2025-OSCA\OCA-OpenStack\python-openstackclient\openstackclient\tests\unit\compute\v2\test_server.py", line 2736, in test_server_create_with_block_device_from_file
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\lenovo\Git\2025-OSCA\OCA-OpenStack\python-openstackclient\openstackclient\tests\unit\utils.py", line 86, in check_parser
parsed_args = cmd_parser.parse_args(args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\argparse.py", line 1904, in parse_args
args, argv = self.parse_known_args(args, namespace)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\argparse.py", line 1914, in parse_known_args
return self._parse_known_args2(args, namespace, intermixed=False)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\argparse.py", line 1943, in _parse_known_args2
namespace, args = self._parse_known_args(args, namespace, intermixed)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\argparse.py", line 2184, in _parse_known_args
start_index = consume_optional(start_index)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\argparse.py", line 2113, in consume_optional
take_action(action, args, option_string)
File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.2800.0_x64__qbz5n2kfra8p0\Lib\argparse.py", line 2018, in take_action
action(self, namespace, argument_values, option_string)
File "C:\Users\lenovo\Git\2025-OSCA\OCA-OpenStack\python-openstackclient\openstackclient\compute\v2\server.py", line 1068, in __call__
with open(values) as fh:
^^^^^^^^^^^^
PermissionError: [Errno 13] Permission denied: 'C:\\Users\\lenovo\\AppData\\Local\\Temp\\tmpgr_188ci'
또한 현재 저는 Window 환경에서 테스트를 수행하고 있기 때문에 위와 같이 temp 파일을 다루는 테스트 로직 상에서 PermissionError가 발생하였습니다.
# python-openstackclient/openstackclient/tests/unit/compute/v2/test_server.py
class
import os
...
TestServerCreate(TestServer):
...
def test_server_create_with_block_device_from_file(self):
self.set_compute_api_version('2.67')
block_device = {
'uuid': self.volume.id,
'source_type': 'volume',
'destination_type': 'volume',
'disk_bus': 'ide',
'device_type': 'disk',
'device_name': 'sdb',
'guest_format': 'ext4',
'volume_size': 64,
'volume_type': 'foo',
'boot_index': 1,
'delete_on_termination': True,
'tag': 'foo',
}
# with 문을 제거하고 파일을 직접 열기
fp = tempfile.NamedTemporaryFile(mode='w+', delete=False)
# 테스트 종료 후 파일을 자동으로 삭제하도록 예약
self.addCleanup(os.remove, fp.name)
json.dump(block_device, fp=fp)
fp.flush()
# 파일 핸들을 명시적으로 닫아줌
fp.close()
# with tempfile.NamedTemporaryFile(mode='w+') as fp:
# json.dump(block_device, fp=fp)
# fp.flush()
arglist = [
'--image',
self.image.id,
'--flavor',
self.flavor.id,
'--block-device',
fp.name,
self.server.name,
]
verifylist = [
('image', self.image.id),
('flavor', self.flavor.id),
('block_devices', [block_device]),
('server_name', self.server.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
이는 Windows 환경에서 tempfile로 생성된 임시 파일에 접근할 때 발생하는 전형적인 권한 문제로 test_server_create_with_block_device_from_file() 메소드에서 with 문을 제거하고 직접 파일을 생성하고 테스트 종료 후 자동으로 삭제하도록 예약합니다.
최종적으로 위와 같이 새롭게 만들어진 Fake Identity에 대해서 모든 테스트가 정상적으로 수행됨을 볼 수 있었습니다.
멘토님께서 말씀주신 내용에 따라 새로운 Fake Identity가 아니라, 기존에 생성된 Fake Identity를 사용하는 방식으로 변경을 시도해볼 예정입니다.

Encryption을 사용하거나 외부 Payload를 사용export OS_AUTH_URL=http://<Barbican 설치 DevStack>/identity
export OS_USERNAME="_"
export OS_PASSWORD="_"
export OS_PROJECT_DOMAIN_NAME="default"
export OS_PROJECT_NAME="admin"
export OS_USER_DOMAIN_NAME="default"
export OS_REGION_NAME="RegionOne"
export OS_INTERFACE="public"
export OS_IDENTITY_API_VERSION=3
export OS_AUTH_TYPE="password"
기존에 설정한 DevStack에서 임시로 환경변수를 위와 같이 설정하여 Barbican이 설치된 DevStack에 접근할 수 있었습니다.
ubuntu@msy-devstack:~$ openstack secret list
+-------------+----------+----------+--------+---------------+-----------+------------+-------------+------+------------+
| Secret href | Name | Created | Status | Content types | Algorithm | Bit length | Secret type | Mode | Expiration |
+-------------+----------+----------+--------+---------------+-----------+------------+-------------+------+------------+
| http://---. | my- | 2025-08- | ACTIVE | {'default': | aes | 256 | opaque | cbc | None |
| ----------- | secret2 | 05T05:49 | | 'text/plain'} | | | | | |
| /key-manage | | :34+00:0 | | | | | | | |
| r/v1/secret | | 0 | | | | | | | |
| s/3f7a9299- | | | | | | | | | |
| 0c3c-45c3- | | | | | | | | | |
| 941a- | | | | | | | | | |
| de088d76a38 | | | | | | | | | |
| c | | | | | | | | | |
| http://---. | my- | 2025-08- | ACTIVE | {'default': | aes | 256 | opaque | cbc | None |
| ----------- | secret | 05T05:14 | | 'text/plain'} | | | | | |
| /key-manage | | :39+00:0 | | | | | | | |
| r/v1/secret | | 0 | | | | | | | |
| s/90576150- | | | | | | | | | |
| df00-45b0- | | | | | | | | | |
| bf05- | | | | | | | | | |
| 0b0e25b0994 | | | | | | | | | |
| 3 | | | | | | | | | |
+-------------+----------+----------+--------+---------------+-----------+------------+-------------+------+------------+
또한 위와 같이 성공적으로 Secret을 받아오는 것을 볼 수 있었다.
$ openstack secret store \
--name "msy-secret" \
--payload "msy-secret-password" \
--payload-content-type text/plain
+---------------+---------------------------------------------------------------------+
| Field | Value |
+---------------+---------------------------------------------------------------------+
| Secret href | http://_/key- |
| | manager/v1/secrets/5a985775-46da-4829-865e-4954ca95c68c |
| Name | msy-secret |
| Created | None |
| Status | None |
| Content types | {'default': 'text/plain'} |
| Algorithm | aes |
| Bit length | 256 |
| Secret type | opaque |
| Mode | cbc |
| Expiration | None |
+---------------+---------------------------------------------------------------------+
$ openstack secret get 5a985775-46da-4829-865e-4954ca95c68c --payload
+---------+---------------------+
| Field | Value |
+---------+---------------------+
| Payload | msy-secret-password |
+---------+---------------------+
$ openstack secret get 5a985775-46da-4829-865e-4954ca95c68c --decrypt
+---------+---------------------+
| Field | Value |
+---------+---------------------+
| Payload | msy-secret-password |
+---------+---------------------+
$ openstack secret get 5a985775-46da-4829-865e-4954ca95c68c --file msy-secret.txt
$ cat msy-secret.txt
msy-secret-password
# secret을 하나 더 생성
$ openstack secret store \
--name "msy-secret-2" \
--payload "msy-secret-password2" \
--payload-content-type text/plain
# 두개의 secret을 통해 container 생성
op secret container create --name "msy-container-name" \
--type generic \
--secret "msy_key_1={$secret-url-1}" \
--secret "msy_key-2={$secret-url-2}"
+----------------+--------------------------------------------------------------------+
| Field | Value |
+----------------+--------------------------------------------------------------------+
| Container href | http://---------------/key- |
| | manager/v1/containers/fc0ab38d-d8c8-4bbc-bbdc-75aa6b5d1013 |
| Name | msy-container-name |
| Created | None |
| Status | ACTIVE |
| Type | generic |
| Secrets | msy_key_1=http://---------------/key- |
| | manager/v1/secrets/5a985775-46da-4829-865e-4954ca95c68c |
| | msy_key-2=http://---------------/key- |
| | manager/v1/secrets/80abea49-581a-4729-95c7-6f5f17ed41aa |
| Consumers | None |
+----------------+--------------------------------------------------------------------+
openstack secret container create --name "msy-container-name-certificate" \
--type certificate \
--secret "msy_key_1={$secret-url-1}" \
--secret "msy_key-2={$secret-url-2}"
+----------------+-----------------------------------------------------------------------------------+
| Field | Value |
+----------------+-----------------------------------------------------------------------------------+
| Container href | http://_______________/key- |
| | manager/v1/containers/b30f942d-d9cf-4d0d-8d9c-3700fbee238b |
| Name | msy-container-name-certificate |
| Created | None |
| Status | ACTIVE |
| Type | certificate |
| Certificate | None |
| Intermediates | None |
| Private Key | None |
| PK Passphrase | None |
| Consumers | None |
+----------------+-----------------------------------------------------------------------------------+
Barbican은 --type certificate 컨테이너를 생성할 때, 어떤 시크릿이 '인증서'이고 어떤 것이 '개인 키'인지 구분하기 위해 정해진 키 이름을 찾습니다.
따라서 msy_key_1이 무엇을 의미하는지 알 수 없으므로, 이를 무시하고 Certificate, Private Key 등의 주요 필드를 None으로 남겨둔 채 비어있는 컨테이너 셸만 생성한 것입니다.
openstack secret container create \
--name "msy-container-certificate-tls" \
--type certificate \
--secret "certificate=http://---------------/key-manager/v1/secrets/5a985775-46da-4829-865e-4954ca95c68c" \
--secret "private_key=http://---------------/key-manager/v1/secrets/80abea49-581a-4729-95c7-6f5f17ed41aa"
+----------------+-----------------------------------------------------------------------------------+
| Field | Value |
+----------------+-----------------------------------------------------------------------------------+
| Container href | http://---------------/key- |
| | manager/v1/containers/b8d8e5ae-e233-4d28-961c-9b3eac6c7f63 |
| Name | msy-container-certificate-tls |
| Created | None |
| Status | ACTIVE |
| Type | certificate |
| Certificate | http://---------------/key- |
| | manager/v1/secrets/5a985775-46da-4829-865e-4954ca95c68c |
| Intermediates | None |
| Private Key | http://---------------/key- |
| | manager/v1/secrets/80abea49-581a-4729-95c7-6f5f17ed41aa |
| PK Passphrase | None |
| Consumers | None |
+----------------+-----------------------------------------------------------------------------------+
따라서 위와 같이 올바른 키 이름으로 명령어를 실행하면, 출력 결과의 Certificate와 Private Key 필드가 정상적으로 채워지는 것을 확인할 수 있었습니다.
이번 4주차 강의 도중 [Keystone]의 openstack token issue를 선택하여 이에 대한 Functional Test를 구현하게 될 예정입니다.

opendev를 사용
https://docs.openstack.org/contributors/ko_KR/common/git.html
<Short Summary Line (<= 50 chars)>
<Optional Detailed Explanation (wrap at 72 chars)>
Closes-Bug: #123456
Change-Id: Iabc123... (auto-generated by Gerrit)
openstack toiken issue
새로운 인증 정보를 입력받아 토큰을 생성하는 것이 아니라, 현재 인증된 (로그인된) 세션의 토큰 정보를 다시 조회하여 보여주는 역할을 합니다.
즉, 이미 발급받은 토큰의 ID, 만료 시간, 스코프(프로젝트, 도메인 등)와 같은 상세 정보를 확인하는 데 사용됩니다.
# openstackclient/identity/v3/token.py
class IssueToken(command.ShowOne):
_description = _("Issue new token")
# scoped token is optional
required_scope = False
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
return parser
def take_action(self, parsed_args):
auth_ref = self.app.client_manager.auth_ref
if not auth_ref:
raise exceptions.AuthorizationFailure(
_("Only an authorized user may issue a new token.")
)
data = {}
if auth_ref.auth_token:
data['id'] = auth_ref.auth_token
if auth_ref.expires:
datetime_obj = auth_ref.expires
expires_str = datetime_obj.strftime('%Y-%m-%dT%H:%M:%S%z')
data['expires'] = expires_str
if auth_ref.project_id:
data['project_id'] = auth_ref.project_id
if auth_ref.user_id:
data['user_id'] = auth_ref.user_id
if auth_ref.domain_id:
data['domain_id'] = auth_ref.domain_id
if auth_ref.system_scoped:
# NOTE(lbragstad): This could change in the future when, or if,
# keystone supports the ability to scope to a subset of the entire
# deployment system. When that happens, this will have to relay
# scope information and IDs like we do for projects and domains.
data['system'] = 'all'
return zip(*sorted(data.items()))
우선 실제로 openstack token issue 명령어 사용법을 분석하기 이전에 openstackclient 코드를 분석하였습니다.
IssueToken 클래스command.ShowOne을 상속받습니다.openstackclient 라이브러리에서 ShowOne은 단일 객체의 정보를 Key-Value 형태의 테이블로 출력하는 데 사용되는 기반 클래스입니다.$ openstack token issue
+------------+------------------------------------------------------------------+
| Field | Value |
+------------+------------------------------------------------------------------+
| domain_id | default |
| expires | 2025-08-08T09:04:05+0000 |
| id | gAAAAABm3a... |
| project_id | 2f83de4f5f5c439999321e244b4c4e7f |
| user_id | 8a9a6b8b3e4c4e5a8f6d7e8f9a0b1c2d |
+------------+------------------------------------------------------------------+이러한 상속 구조에 의해 openstack token issue의 결과가 위처럼 2열 테이블 형식으로 나타납니다.get_parser()super()를 호출하기만 하고 추가적인 인자를 정의하지 않습니다.openstack token issue 명령어가 자체적인 옵션을 가지지 않고, OpenStack 클라이언트의 전역 인증 옵션(OS_PROJECT_ID 환경변수 등)에 의존한다는 것을 의미합니다.take_action()
a. 인증 정보 로드:
self.app.client_manager.auth_ref를 통해 현재 클라이언트 세션의 인증 정보 객체인 auth_ref를 가져옵니다.AuthorizationFailure 예외를 발생시킵니다.b. 데이터 추출 및 구성: auth_ref 객체에서 필요한 정보들을 추출하여 data 딕셔너리에 저장합니다.
c. 출력 데이터 반환:
return zip(*sorted(data.items()))data.items(): (key, value) 튜플의 리스트를 만듭니다.sorted(): 키(Field)를 기준으로 알파벳순으로 정렬합니다.ShowOne 기반 클래스가 이를 받아 테이블 형식으로 출력합니다.ubuntu@msy-devstack:~$ openstack --version
openstack 7.5.0
ubuntu@msy-devstack:~$ openstack token issue --help
usage: openstack token issue [-h] [-f {json,shell,table,value,yaml}] [-c COLUMN] [--noindent]
[--prefix PREFIX] [--max-width <integer>] [--fit-width]
[--print-empty]
Issue new token
options:
-h, --help show this help message and exit
output formatters:
output formatter options
-f {json,shell,table,value,yaml}, --format {json,shell,table,value,yaml}
the output format, defaults to table
-c COLUMN, --column COLUMN
specify the column(s) to include, can be repeated to show multiple columns
json formatter:
--noindent whether to disable indenting the JSON
shell formatter:
a format a UNIX shell can parse (variable="value")
--prefix PREFIX
add a prefix to all variable names
table formatter:
--max-width <integer>
Maximum display width, <1 to disable. You can also use the CLIFF_MAX_TERM_WIDTH
environment variable, but the parameter takes precedence.
--fit-width Fit the table to the display width. Implied if --max-width greater than 0. Set the
environment variable CLIFF_FIT_WIDTH=1 to always enable
--print-empty Print empty table if there is no data to show.
이후 위와 같이 NHN의 DevStack이 설치된 인스턴스에서 openstack 7.5.0 버전의 openstack token issue 명령어의 사용법을 출력하였습니다.
openstack token issue [option]
일반 옵션
-h, --help: 도움말 메시지를 보여주고 종료합니다.출력 형식 옵션
-f, --format {json|shell|table|value|yaml}: 결과의 출력 형식을 지정합니다. (기본값: table)-c, --column <COLUMN>: 출력 결과에서 보고 싶은 특정 column만 지정합니다.Table 형식 옵션 (기본)
--max-width <숫자>: 테이블의 최대 너비를 지정합니다.--fit-width: 현재 터미널 창의 너비에 맞춰 테이블 크기를 자동으로 조절합니다.--print-empty: 표시할 데이터가 없을 경우, 빈 테이블의 틀이라도 출력합니다.JSON 형식 옵션
--noindent: JSON 출력 시 자동으로 적용되는 들여쓰기를 비활성화하여 한 줄로 출력합니다.Shell 형식 옵션
--prefix <PREFIX>: 변수명="값" 형태로 출력할 때, 모든 변수명 앞에 지정한 접두사를 붙입니다.명령어의 옵션과 각 파라미터는 위와 같이 정리할 수 있었습니다.
# /openstackclient/openstackclient/tests/functional/identity/v3/test_token.py
from openstackclient.tests.functional.identity.v3 import common
class TokenTests(common.IdentityTests):
def test_token_issue(self):
raw_output = self.openstack('token issue')
items = self.parse_show(raw_output)
self.assert_show_fields(items, self.TOKEN_FIELDS)
기존 Functional Test 코드는 self.assert_show_fields(items, self.TOKEN_FIELDS)을 통해 openstack token issue 명령어를 실행하고 나온 기본 table 형식의 출력을 분석합니다.
즉, 해당 테스트는 기대하는 필드들이 모두 포함되어 있는지 정도의 기본적인 구조만 검증할 뿐 직접적으로 어떠한 값이 존재하는지는 확인하지 않습니다.
# /openstackclient/openstackclient/tests/functional/identity/v3/test_token.py
import json
from openstackclient.tests.functional.identity.v3 import common
# 순수한 테스트 메소드
class TokenTests(common.IdentityTests):
# TC1: 기본 테이블 형식 출력 검증
def test_token_issue_table_format(self):
raw_output = self.openstack('token issue')
self.assertIn('expires', raw_output)
self.assertIn('project_id', raw_output)
self.assertIn('user_id', raw_output)
# TC2: JSON 형식 출력 검증
def test_token_issue_json_format(self):
raw_output = self.openstack('token issue --format json')
data = json.loads(raw_output)
self.assertIn('id', data)
self.assertIn('project_id', data)
self.assertIn('user_id', data)
# TC3: 특정 컬럼만 값으로 출력하는지 검증
def test_token_issue_single_column(self):
# project_id 필드가 존재하는지만 확인
raw_output = self.openstack(
'token issue --format value --column project_id'
)
# 출력이 비어있지 않은지만 확인
self.assertNotEqual('', raw_output.strip())
따라서 위와 같이 명령어의 출력 포맷이 올바르게 작동하는지 검증할 수 있도록 테스트 코드를 수정하였습니다.
openstack('command'): OpenStack CLI 명령어를 실행하고 그 결과를 문자열로 반환합니다.assertIn(expected, actual): actual 문자열 안에 expected 문자열이 포함되어 있는지 확인합니다.assertEqual(expected, actual): expected 값과 actual 값이 정확히 동일한지 확인합니다.테스트를 구성하는 주요 메소드는 위와 같습니다.
# .stestr.conf
[DEFAULT]
# test_path=./openstackclient/tests/unit
test_path=./openstackclient/tests/functional
top_dir=./
group_regex=([^\.]+\.)+
이후 위와 같이 Functional Test를 진행해주기 위해 test_path를 수정해줍니다.
# tox.ini
[testenv:unit-tips]
commands =
python -m pip install -q -U -e {toxinidir}/../cliff#egg=cliff
python -m pip install -q -U -e {toxinidir}/../keystoneauth#egg=keystoneauth
python -m pip install -q -U -e {toxinidir}/../osc-lib#egg=osc_lib
python -m pip install -q -U -e {toxinidir}/../openstacksdk#egg=openstacksdk
python -m pip freeze
stestr run {posargs}
[testenv:functional{,-tips,-py38,-py39,-py310,-py311,-py312}]
description =
Run functional tests.
setenv =
OS_TEST_PATH=./openstackclient/tests/functional
# 인코딩 문제 해결
PYTHONUTF8=1
passenv =
OS_*
commands =
tips: python -m pip install -q -U -e {toxinidir}/../cliff#egg=cliff
tips: python -m pip install -q -U -e {toxinidir}/../keystoneauth#egg=keystoneauth1
tips: python -m pip install -q -U -e {toxinidir}/../osc-lib#egg=osc_lib
tips: python -m pip install -q -U -e {toxinidir}/../openstacksdk#egg=openstacksdk
tips: python -m pip freeze
{[testenv]commands}
또한 위와 같이 PYTHONUTF8=1를 추가하여 테스트 환경에서의 인코딩 문제를 해결해주고
# python-openstackclient/clouds.yaml
clouds:
devstack-admin:
auth:
auth_url: http://<MSY-DEVSTACK-PUB-IP>/identity
username: "admin"
password: "<DEVSTACK-PW>"
project_domain_name: "default"
project_name: "admin"
user_domain_name: "default"
auth_type: "password"
region_name: "RegionOne"
interface: "public"
identity_api_version: 3
테스트를 진행할 때의 접속 정보를 clouds.yaml파일에 위와 같이 추가해주고
$ tox -e functional -- "openstackclient.tests.functional.identity.v3.test_token.TokenTests"
위 명령어를 통해 수정한 identity.v3.test_token.TokenTests 클래스에 대해서만 테스트를 수행해주면
$ tox -e functional -- "openstackclient.tests.functional.identity.v3.test_token.TokenTests"
...
functional: install_package> python -I -m pip install --force-reinstall --no-deps C:\...\OCA-OpenStack\python-openstackclient\.tox\.tmp\package\28\python_openstackclient-8.1.1.dev14-0.editable-py3-none-any.whl
functional: commands[0]> stestr run openstackclient.tests.functional.identity.v3.test_token.TokenTests
{0} openstackclient.tests.functional.identity.v3.test_token.TokenTests.test_token_issue_json_format [1.703573s] ... ok
{0} openstackclient.tests.functional.identity.v3.test_token.TokenTests.test_token_issue_single_column [1.720266s] ... ok
{0} openstackclient.tests.functional.identity.v3.test_token.TokenTests.test_token_issue_table_format [1.712172s] ... ok
======
Totals
======
Ran: 3 tests in 5.1360 sec.
- Passed: 3
- Skipped: 0
- Expected Fail: 0
- Unexpected Success: 0
- Failed: 0
Sum of execute time for each test: 5.1360 sec.
==============
Worker Balance
==============
- Worker 0 (3 tests) => 0:00:05.136011
.pkg: _exit> python C:\...\OCA-OpenStack\Lib\site-packages\pyproject_api\_backend.py True pbr.build
functional: OK (29.80=setup[7.67]+cmd[22.12] seconds)
congratulations :) (30.25 seconds)
위와 같이 수정한 Functional Test가 DevStack에서 정상적으로 수행되는 모습을 볼 수 있습니다.