cpu: interfaces for managing state and governor
This is the first stage of the power management series. In order to be able to switch the CPU state or change the governor, we need a framework to access sysfs. As some bits can be reused, let's create a nova.filesystem helper module that will define read-write mechanisms for accessing sysfs-specific commands. Partially-Implements: blueprint libvirt-cpu-state-mgmt Change-Id: Icb913ed9be8d508de35e755a9c650ba25e45aca2
This commit is contained in:
parent
7ea9aac71c
commit
ddf96bcd31
@ -1,6 +1,7 @@
|
||||
nova/compute/manager.py
|
||||
nova/compute/pci_placement_translator.py
|
||||
nova/crypto.py
|
||||
nova/filesystem.py
|
||||
nova/limit/local.py
|
||||
nova/limit/placement.py
|
||||
nova/network/neutron.py
|
||||
@ -13,6 +14,9 @@ nova/virt/driver.py
|
||||
nova/virt/hardware.py
|
||||
nova/virt/libvirt/machine_type_utils.py
|
||||
nova/virt/libvirt/__init__.py
|
||||
nova/virt/libvirt/cpu/__init__.py
|
||||
nova/virt/libvirt/cpu/api.py
|
||||
nova/virt/libvirt/cpu/core.py
|
||||
nova/virt/libvirt/driver.py
|
||||
nova/virt/libvirt/event.py
|
||||
nova/virt/libvirt/guest.py
|
||||
|
@ -1478,6 +1478,15 @@ Related options:
|
||||
"""),
|
||||
]
|
||||
|
||||
libvirt_cpu_mgmt_opts = [
|
||||
cfg.StrOpt('cpu_power_governor_low',
|
||||
default='powersave',
|
||||
help='Governor to use in order '
|
||||
'to reduce CPU power consumption'),
|
||||
cfg.StrOpt('cpu_power_governor_high',
|
||||
default='performance',
|
||||
help='Governor to use in order to have best CPU performance'),
|
||||
]
|
||||
|
||||
ALL_OPTS = list(itertools.chain(
|
||||
libvirt_general_opts,
|
||||
@ -1499,6 +1508,7 @@ ALL_OPTS = list(itertools.chain(
|
||||
libvirt_volume_nvmeof_opts,
|
||||
libvirt_pmem_opts,
|
||||
libvirt_vtpm_opts,
|
||||
libvirt_cpu_mgmt_opts,
|
||||
))
|
||||
|
||||
|
||||
|
59
nova/filesystem.py
Normal file
59
nova/filesystem.py
Normal file
@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
|
||||
"""Functions to address filesystem calls, particularly sysfs."""
|
||||
|
||||
import os
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
from nova import exception
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SYS = '/sys'
|
||||
|
||||
|
||||
# NOTE(bauzas): this method is deliberately not wrapped in a privsep entrypoint
|
||||
def read_sys(path: str) -> str:
|
||||
"""Reads the content of a file in the sys filesystem.
|
||||
|
||||
:param path: relative or absolute. If relative, will be prefixed by /sys.
|
||||
:returns: contents of that file.
|
||||
:raises: nova.exception.FileNotFound if we can't read that file.
|
||||
"""
|
||||
try:
|
||||
# The path can be absolute with a /sys prefix but that's fine.
|
||||
with open(os.path.join(SYS, path), mode='r') as data:
|
||||
return data.read()
|
||||
except (OSError, ValueError) as exc:
|
||||
raise exception.FileNotFound(file_path=path) from exc
|
||||
|
||||
|
||||
# NOTE(bauzas): this method is deliberately not wrapped in a privsep entrypoint
|
||||
# In order to correctly use it, you need to decorate the caller with a specific
|
||||
# privsep entrypoint.
|
||||
def write_sys(path: str, data: str) -> None:
|
||||
"""Writes the content of a file in the sys filesystem with data.
|
||||
|
||||
:param path: relative or absolute. If relative, will be prefixed by /sys.
|
||||
:param data: the data to write.
|
||||
:returns: contents of that file.
|
||||
:raises: nova.exception.FileNotFound if we can't write that file.
|
||||
"""
|
||||
try:
|
||||
# The path can be absolute with a /sys prefix but that's fine.
|
||||
with open(os.path.join(SYS, path), mode='w') as fd:
|
||||
fd.write(data)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise exception.FileNotFound(file_path=path) from exc
|
52
nova/tests/unit/test_filesystem.py
Normal file
52
nova/tests/unit/test_filesystem.py
Normal file
@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from nova import exception
|
||||
from nova import filesystem
|
||||
from nova import test
|
||||
|
||||
|
||||
class TestFSCommon(test.NoDBTestCase):
|
||||
|
||||
def test_read_sys(self):
|
||||
open_mock = mock.mock_open(read_data='bar')
|
||||
with mock.patch('builtins.open', open_mock) as m_open:
|
||||
self.assertEqual('bar', filesystem.read_sys('foo'))
|
||||
expected_path = os.path.join(filesystem.SYS, 'foo')
|
||||
m_open.assert_called_once_with(expected_path, mode='r')
|
||||
|
||||
def test_read_sys_error(self):
|
||||
with mock.patch('builtins.open',
|
||||
side_effect=OSError('error')) as m_open:
|
||||
self.assertRaises(exception.FileNotFound,
|
||||
filesystem.read_sys, 'foo')
|
||||
expected_path = os.path.join(filesystem.SYS, 'foo')
|
||||
m_open.assert_called_once_with(expected_path, mode='r')
|
||||
|
||||
def test_write_sys(self):
|
||||
open_mock = mock.mock_open()
|
||||
with mock.patch('builtins.open', open_mock) as m_open:
|
||||
self.assertIsNone(filesystem.write_sys('foo', 'bar'))
|
||||
expected_path = os.path.join(filesystem.SYS, 'foo')
|
||||
m_open.assert_called_once_with(expected_path, mode='w')
|
||||
open_mock().write.assert_called_once_with('bar')
|
||||
|
||||
def test_write_sys_error(self):
|
||||
with mock.patch('builtins.open',
|
||||
side_effect=OSError('fake_error')) as m_open:
|
||||
self.assertRaises(exception.FileNotFound,
|
||||
filesystem.write_sys, 'foo', 'bar')
|
||||
expected_path = os.path.join(filesystem.SYS, 'foo')
|
||||
m_open.assert_called_once_with(expected_path, mode='w')
|
0
nova/tests/unit/virt/libvirt/cpu/__init__.py
Normal file
0
nova/tests/unit/virt/libvirt/cpu/__init__.py
Normal file
63
nova/tests/unit/virt/libvirt/cpu/test_api.py
Normal file
63
nova/tests/unit/virt/libvirt/cpu/test_api.py
Normal file
@ -0,0 +1,63 @@
|
||||
# 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 unittest import mock
|
||||
|
||||
from nova import test
|
||||
from nova.virt.libvirt.cpu import api
|
||||
from nova.virt.libvirt.cpu import core
|
||||
|
||||
|
||||
class TestAPI(test.NoDBTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestAPI, self).setUp()
|
||||
self.core_1 = api.Core(1)
|
||||
|
||||
@mock.patch.object(core, 'get_online')
|
||||
def test_online(self, mock_get_online):
|
||||
mock_get_online.return_value = True
|
||||
self.assertTrue(self.core_1.online)
|
||||
mock_get_online.assert_called_once_with(self.core_1.ident)
|
||||
|
||||
@mock.patch.object(core, 'set_online')
|
||||
def test_set_online(self, mock_set_online):
|
||||
self.core_1.online = True
|
||||
mock_set_online.assert_called_once_with(self.core_1.ident)
|
||||
|
||||
@mock.patch.object(core, 'set_offline')
|
||||
def test_set_offline(self, mock_set_offline):
|
||||
self.core_1.online = False
|
||||
mock_set_offline.assert_called_once_with(self.core_1.ident)
|
||||
|
||||
def test_hash(self):
|
||||
self.assertEqual(hash(self.core_1.ident), hash(self.core_1))
|
||||
|
||||
@mock.patch.object(core, 'get_governor')
|
||||
def test_governor(self, mock_get_governor):
|
||||
mock_get_governor.return_value = 'fake_governor'
|
||||
self.assertEqual('fake_governor', self.core_1.governor)
|
||||
mock_get_governor.assert_called_once_with(self.core_1.ident)
|
||||
|
||||
@mock.patch.object(core, 'set_governor')
|
||||
def test_set_governor_low(self, mock_set_governor):
|
||||
self.flags(cpu_power_governor_low='fake_low_gov', group='libvirt')
|
||||
self.core_1.set_low_governor()
|
||||
mock_set_governor.assert_called_once_with(self.core_1.ident,
|
||||
'fake_low_gov')
|
||||
|
||||
@mock.patch.object(core, 'set_governor')
|
||||
def test_set_governor_high(self, mock_set_governor):
|
||||
self.flags(cpu_power_governor_high='fake_high_gov', group='libvirt')
|
||||
self.core_1.set_high_governor()
|
||||
mock_set_governor.assert_called_once_with(self.core_1.ident,
|
||||
'fake_high_gov')
|
122
nova/tests/unit/virt/libvirt/cpu/test_core.py
Normal file
122
nova/tests/unit/virt/libvirt/cpu/test_core.py
Normal file
@ -0,0 +1,122 @@
|
||||
# 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 unittest import mock
|
||||
|
||||
from nova import exception
|
||||
from nova import test
|
||||
from nova.tests import fixtures
|
||||
from nova.virt.libvirt.cpu import core
|
||||
|
||||
|
||||
class TestCore(test.NoDBTestCase):
|
||||
|
||||
@mock.patch.object(core.filesystem, 'read_sys')
|
||||
@mock.patch.object(core.hardware, 'parse_cpu_spec')
|
||||
def test_get_available_cores(self, mock_parse_cpu_spec, mock_read_sys):
|
||||
mock_read_sys.return_value = '1-2'
|
||||
mock_parse_cpu_spec.return_value = set([1, 2])
|
||||
self.assertEqual(set([1, 2]), core.get_available_cores())
|
||||
mock_read_sys.assert_called_once_with(core.AVAILABLE_PATH)
|
||||
mock_parse_cpu_spec.assert_called_once_with('1-2')
|
||||
|
||||
@mock.patch.object(core.filesystem, 'read_sys')
|
||||
@mock.patch.object(core.hardware, 'parse_cpu_spec')
|
||||
def test_get_available_cores_none(
|
||||
self, mock_parse_cpu_spec, mock_read_sys):
|
||||
mock_read_sys.return_value = ''
|
||||
self.assertEqual(set(), core.get_available_cores())
|
||||
mock_parse_cpu_spec.assert_not_called()
|
||||
|
||||
@mock.patch.object(core, 'get_available_cores')
|
||||
def test_exists(self, mock_get_available_cores):
|
||||
mock_get_available_cores.return_value = set([1])
|
||||
self.assertTrue(core.exists(1))
|
||||
mock_get_available_cores.assert_called_once_with()
|
||||
self.assertFalse(core.exists(2))
|
||||
|
||||
@mock.patch.object(
|
||||
core, 'CPU_PATH_TEMPLATE',
|
||||
new_callable=mock.PropertyMock(return_value='/sys/blah%(core)s'))
|
||||
@mock.patch.object(core, 'exists')
|
||||
def test_gen_cpu_path(self, mock_exists, mock_cpu_path):
|
||||
mock_exists.return_value = True
|
||||
self.assertEqual('/sys/blah1', core.gen_cpu_path(1))
|
||||
mock_exists.assert_called_once_with(1)
|
||||
|
||||
@mock.patch.object(core, 'exists')
|
||||
def test_gen_cpu_path_raises(self, mock_exists):
|
||||
mock_exists.return_value = False
|
||||
self.assertRaises(ValueError, core.gen_cpu_path, 1)
|
||||
self.assertIn('Unable to access CPU: 1', self.stdlog.logger.output)
|
||||
|
||||
|
||||
class TestCoreHelpers(test.NoDBTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestCoreHelpers, self).setUp()
|
||||
self.useFixture(fixtures.PrivsepFixture())
|
||||
_p1 = mock.patch.object(core, 'exists', return_value=True)
|
||||
self.mock_exists = _p1.start()
|
||||
self.addCleanup(_p1.stop)
|
||||
|
||||
_p2 = mock.patch.object(core, 'gen_cpu_path',
|
||||
side_effect=lambda x: '/fakesys/blah%s' % x)
|
||||
self.mock_gen_cpu_path = _p2.start()
|
||||
self.addCleanup(_p2.stop)
|
||||
|
||||
@mock.patch.object(core.filesystem, 'read_sys')
|
||||
def test_get_online(self, mock_read_sys):
|
||||
mock_read_sys.return_value = '1'
|
||||
self.assertTrue(core.get_online(1))
|
||||
mock_read_sys.assert_called_once_with('/fakesys/blah1/online')
|
||||
|
||||
@mock.patch.object(core.filesystem, 'read_sys')
|
||||
def test_get_online_not_exists(self, mock_read_sys):
|
||||
mock_read_sys.side_effect = exception.FileNotFound(file_path='foo')
|
||||
self.assertTrue(core.get_online(1))
|
||||
mock_read_sys.assert_called_once_with('/fakesys/blah1/online')
|
||||
|
||||
@mock.patch.object(core.filesystem, 'write_sys')
|
||||
@mock.patch.object(core, 'get_online')
|
||||
def test_set_online(self, mock_get_online, mock_write_sys):
|
||||
mock_get_online.return_value = True
|
||||
self.assertTrue(core.set_online(1))
|
||||
mock_write_sys.assert_called_once_with('/fakesys/blah1/online',
|
||||
data='1')
|
||||
mock_get_online.assert_called_once_with(1)
|
||||
|
||||
@mock.patch.object(core.filesystem, 'write_sys')
|
||||
@mock.patch.object(core, 'get_online')
|
||||
def test_set_offline(self, mock_get_online, mock_write_sys):
|
||||
mock_get_online.return_value = False
|
||||
self.assertTrue(core.set_offline(1))
|
||||
mock_write_sys.assert_called_once_with('/fakesys/blah1/online',
|
||||
data='0')
|
||||
mock_get_online.assert_called_once_with(1)
|
||||
|
||||
@mock.patch.object(core.filesystem, 'read_sys')
|
||||
def test_get_governor(self, mock_read_sys):
|
||||
mock_read_sys.return_value = 'fake_gov'
|
||||
self.assertEqual('fake_gov', core.get_governor(1))
|
||||
mock_read_sys.assert_called_once_with(
|
||||
'/fakesys/blah1/cpufreq/scaling_governor')
|
||||
|
||||
@mock.patch.object(core, 'get_governor')
|
||||
@mock.patch.object(core.filesystem, 'write_sys')
|
||||
def test_set_governor(self, mock_write_sys, mock_get_governor):
|
||||
mock_get_governor.return_value = 'fake_gov'
|
||||
self.assertEqual('fake_gov',
|
||||
core.set_governor(1, 'fake_gov'))
|
||||
mock_write_sys.assert_called_once_with(
|
||||
'/fakesys/blah1/cpufreq/scaling_governor', data='fake_gov')
|
||||
mock_get_governor.assert_called_once_with(1)
|
16
nova/virt/libvirt/cpu/__init__.py
Normal file
16
nova/virt/libvirt/cpu/__init__.py
Normal file
@ -0,0 +1,16 @@
|
||||
# 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 nova.virt.libvirt.cpu import api
|
||||
|
||||
|
||||
Core = api.Core
|
62
nova/virt/libvirt/cpu/api.py
Normal file
62
nova/virt/libvirt/cpu/api.py
Normal file
@ -0,0 +1,62 @@
|
||||
# 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 dataclasses import dataclass
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
import nova.conf
|
||||
from nova.virt.libvirt.cpu import core
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
CONF = nova.conf.CONF
|
||||
|
||||
|
||||
@dataclass
|
||||
class Core:
|
||||
"""Class to model a CPU core as reported by sysfs.
|
||||
|
||||
It may be a physical CPU core or a hardware thread on a shared CPU core
|
||||
depending on if the system supports SMT.
|
||||
"""
|
||||
|
||||
# NOTE(sbauza): ident is a mandatory field.
|
||||
# The CPU core id/number
|
||||
ident: int
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
return core.get_online(self.ident)
|
||||
|
||||
@online.setter
|
||||
def online(self, state: bool) -> None:
|
||||
if state:
|
||||
core.set_online(self.ident)
|
||||
else:
|
||||
core.set_offline(self.ident)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.ident)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.ident == other.ident
|
||||
|
||||
@property
|
||||
def governor(self) -> str:
|
||||
return core.get_governor(self.ident)
|
||||
|
||||
def set_high_governor(self) -> None:
|
||||
core.set_governor(self.ident, CONF.libvirt.cpu_power_governor_high)
|
||||
|
||||
def set_low_governor(self) -> None:
|
||||
core.set_governor(self.ident, CONF.libvirt.cpu_power_governor_low)
|
78
nova/virt/libvirt/cpu/core.py
Normal file
78
nova/virt/libvirt/cpu/core.py
Normal file
@ -0,0 +1,78 @@
|
||||
# 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.
|
||||
|
||||
import os
|
||||
import typing as ty
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
from nova import exception
|
||||
from nova import filesystem
|
||||
import nova.privsep
|
||||
from nova.virt import hardware
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
AVAILABLE_PATH = '/sys/devices/system/cpu/present'
|
||||
|
||||
CPU_PATH_TEMPLATE = '/sys/devices/system/cpu/cpu%(core)s'
|
||||
|
||||
|
||||
def get_available_cores() -> ty.Set[int]:
|
||||
cores = filesystem.read_sys(AVAILABLE_PATH)
|
||||
return hardware.parse_cpu_spec(cores) if cores else set()
|
||||
|
||||
|
||||
def exists(core: int) -> bool:
|
||||
return core in get_available_cores()
|
||||
|
||||
|
||||
def gen_cpu_path(core: int) -> str:
|
||||
if not exists(core):
|
||||
LOG.warning('Unable to access CPU: %s', core)
|
||||
raise ValueError('CPU: %(core)s does not exist', core)
|
||||
return CPU_PATH_TEMPLATE % {'core': core}
|
||||
|
||||
|
||||
def get_online(core: int) -> bool:
|
||||
try:
|
||||
online = filesystem.read_sys(
|
||||
os.path.join(gen_cpu_path(core), 'online')).strip()
|
||||
except exception.FileNotFound:
|
||||
# The online file may not exist if we haven't written it yet.
|
||||
# By default, this means that the CPU is online.
|
||||
online = '1'
|
||||
return online == '1'
|
||||
|
||||
|
||||
@nova.privsep.sys_admin_pctxt.entrypoint
|
||||
def set_online(core: int) -> bool:
|
||||
filesystem.write_sys(os.path.join(gen_cpu_path(core), 'online'), data='1')
|
||||
return get_online(core)
|
||||
|
||||
|
||||
def set_offline(core: int) -> bool:
|
||||
filesystem.write_sys(os.path.join(gen_cpu_path(core), 'online'), data='0')
|
||||
return not get_online(core)
|
||||
|
||||
|
||||
def get_governor(core: int) -> str:
|
||||
return filesystem.read_sys(
|
||||
os.path.join(gen_cpu_path(core), 'cpufreq/scaling_governor')).strip()
|
||||
|
||||
|
||||
@nova.privsep.sys_admin_pctxt.entrypoint
|
||||
def set_governor(core: int, governor: str) -> str:
|
||||
filesystem.write_sys(
|
||||
os.path.join(gen_cpu_path(core), 'cpufreq/scaling_governor'),
|
||||
data=governor)
|
||||
return get_governor(core)
|
Loading…
Reference in New Issue
Block a user