Skip to content

Commit 171eb4f

Browse files
authored
[sonic_installer] Add swap setup support (sonic-net#1787)
What I did Let's add swap memory setup support for sonic_installer command so it could run on devices with limited memory resources. How I did it Add the following new options to sonic_installer: * --skip-setup-swap: if present, will skip setup swap memory. * --swap-mem-size: this will change the swap memory size(the default swap size is 1024 MiB) * --total-mem-threshold: if the system total memory is less than the value passed to --total-mem-threshold(default 2048 MiB), sonic_installer will setup swap memory. * --available-mem-threshold: if the system available memory is less than the value passed to --available-mem-threshold(default 1200 MiB), sonic_installer will setup swap memory. Add class MutuallyExclusiveOption to check the mutually-exclusive relationship between options. Add class SWAPAllocator to support swap memory setup/remove functionalities. NOTE: when sonic_installer tries to setup swap, if the system disk free space is less than 4096 MiB, sonic_installer will not setup swap memory. How to verify it Run sonic_installer over devices with limited memory
1 parent 6483b0b commit 171eb4f

File tree

3 files changed

+396
-2
lines changed

3 files changed

+396
-2
lines changed

sonic_installer/main.py

+114-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import subprocess
55
import sys
66
import time
7+
import utilities_common.cli as clicommon
78
from urllib.request import urlopen, urlretrieve
89

910
import click
@@ -367,6 +368,102 @@ def migrate_sonic_packages(bootloader, binary_image_version):
367368
umount(new_image_mount, raise_exception=False)
368369

369370

371+
class SWAPAllocator(object):
372+
"""Context class to allocate SWAP memory."""
373+
374+
SWAP_MEM_SIZE = 1024
375+
DISK_FREESPACE_THRESHOLD = 4 * 1024
376+
TOTAL_MEM_THRESHOLD = 2048
377+
AVAILABLE_MEM_THRESHOLD = 1200
378+
SWAP_FILE_PATH = '/host/swapfile'
379+
KiB_TO_BYTES_FACTOR = 1024
380+
MiB_TO_BYTES_FACTOR = 1024 * 1024
381+
382+
def __init__(self, allocate, swap_mem_size=None, total_mem_threshold=None, available_mem_threshold=None):
383+
"""
384+
Initialize the SWAP memory allocator.
385+
The allocator will try to setup SWAP memory only if all the below conditions are met:
386+
- allocate evaluates to True
387+
- disk has enough space(> DISK_MEM_THRESHOLD)
388+
- either system total memory < total_mem_threshold or system available memory < available_mem_threshold
389+
390+
@param allocate: True to allocate SWAP memory if necessarry
391+
@param swap_mem_size: the size of SWAP memory to allocate(in MiB)
392+
@param total_mem_threshold: the system totla memory threshold(in MiB)
393+
@param available_mem_threshold: the system available memory threshold(in MiB)
394+
"""
395+
self.allocate = allocate
396+
self.swap_mem_size = SWAPAllocator.SWAP_MEM_SIZE if swap_mem_size is None else swap_mem_size
397+
self.total_mem_threshold = SWAPAllocator.TOTAL_MEM_THRESHOLD if total_mem_threshold is None else total_mem_threshold
398+
self.available_mem_threshold = SWAPAllocator.AVAILABLE_MEM_THRESHOLD if available_mem_threshold is None else available_mem_threshold
399+
self.is_allocated = False
400+
401+
@staticmethod
402+
def get_disk_freespace(path):
403+
"""Return free disk space in bytes."""
404+
fs_stats = os.statvfs(path)
405+
return fs_stats.f_bsize * fs_stats.f_bavail
406+
407+
@staticmethod
408+
def read_from_meminfo():
409+
"""Read information from /proc/meminfo."""
410+
meminfo = {}
411+
with open("/proc/meminfo") as fd:
412+
for line in fd.readlines():
413+
if line:
414+
fields = line.split()
415+
if len(fields) >= 2 and fields[1].isdigit():
416+
meminfo[fields[0].rstrip(":")] = int(fields[1])
417+
return meminfo
418+
419+
def setup_swapmem(self):
420+
swapfile = SWAPAllocator.SWAP_FILE_PATH
421+
with open(swapfile, 'wb') as fd:
422+
os.posix_fallocate(fd.fileno(), 0, self.swap_mem_size * SWAPAllocator.MiB_TO_BYTES_FACTOR)
423+
os.chmod(swapfile, 0o600)
424+
run_command(f'mkswap {swapfile}; swapon {swapfile}')
425+
426+
def remove_swapmem(self):
427+
swapfile = SWAPAllocator.SWAP_FILE_PATH
428+
run_command_or_raise(['swapoff', swapfile], raise_exception=False)
429+
try:
430+
os.unlink(swapfile)
431+
finally:
432+
pass
433+
434+
def __enter__(self):
435+
if self.allocate:
436+
if self.get_disk_freespace('/host') < max(SWAPAllocator.DISK_FREESPACE_THRESHOLD, self.swap_mem_size) * SWAPAllocator.MiB_TO_BYTES_FACTOR:
437+
echo_and_log("Failed to setup SWAP memory due to insufficient disk free space...", LOG_ERR)
438+
return
439+
meminfo = self.read_from_meminfo()
440+
mem_total_in_bytes = meminfo["MemTotal"] * SWAPAllocator.KiB_TO_BYTES_FACTOR
441+
mem_avail_in_bytes = meminfo["MemAvailable"] * SWAPAllocator.KiB_TO_BYTES_FACTOR
442+
if (mem_total_in_bytes < self.total_mem_threshold * SWAPAllocator.MiB_TO_BYTES_FACTOR
443+
or mem_avail_in_bytes < self.available_mem_threshold * SWAPAllocator.MiB_TO_BYTES_FACTOR):
444+
echo_and_log("Setup SWAP memory")
445+
swapfile = SWAPAllocator.SWAP_FILE_PATH
446+
if os.path.exists(swapfile):
447+
self.remove_swapmem()
448+
try:
449+
self.setup_swapmem()
450+
except Exception:
451+
self.remove_swapmem()
452+
raise
453+
self.is_allocated = True
454+
455+
def __exit__(self, *exc_info):
456+
if self.is_allocated:
457+
self.remove_swapmem()
458+
459+
460+
def validate_positive_int(ctx, param, value):
461+
"""Callback to validate param passed is a positive integer."""
462+
if isinstance(value, int) and value > 0:
463+
return value
464+
raise click.BadParameter("Must be a positive integer")
465+
466+
370467
# Main entrypoint
371468
@click.group(cls=AliasedGroup)
372469
def sonic_installer():
@@ -389,8 +486,22 @@ def sonic_installer():
389486
help="Do not migrate current configuration to the newly installed image")
390487
@click.option('--skip-package-migration', is_flag=True,
391488
help="Do not migrate current packages to the newly installed image")
489+
@click.option('--skip-setup-swap', is_flag=True,
490+
help='Skip setup temporary SWAP memory used for installation')
491+
@click.option('--swap-mem-size', default=1024, type=int, show_default='1024 MiB',
492+
help='SWAP memory space size', callback=validate_positive_int,
493+
cls=clicommon.MutuallyExclusiveOption, mutually_exclusive=['skip_setup_swap'])
494+
@click.option('--total-mem-threshold', default=2048, type=int, show_default='2048 MiB',
495+
help='If system total memory is lower than threshold, setup SWAP memory',
496+
cls=clicommon.MutuallyExclusiveOption, mutually_exclusive=['skip_setup_swap'],
497+
callback=validate_positive_int)
498+
@click.option('--available-mem-threshold', default=1200, type=int, show_default='1200 MiB',
499+
help='If system available memory is lower than threhold, setup SWAP memory',
500+
cls=clicommon.MutuallyExclusiveOption, mutually_exclusive=['skip_setup_swap'],
501+
callback=validate_positive_int)
392502
@click.argument('url')
393-
def install(url, force, skip_migration=False, skip_package_migration=False):
503+
def install(url, force, skip_migration=False, skip_package_migration=False,
504+
skip_setup_swap=False, swap_mem_size=None, total_mem_threshold=None, available_mem_threshold=None):
394505
""" Install image from local binary or URL"""
395506
bootloader = get_bootloader()
396507

@@ -427,7 +538,8 @@ def install(url, force, skip_migration=False, skip_package_migration=False):
427538
raise click.Abort()
428539

429540
echo_and_log("Installing image {} and setting it as default...".format(binary_image_version))
430-
bootloader.install_image(image_path)
541+
with SWAPAllocator(not skip_setup_swap, swap_mem_size, total_mem_threshold, available_mem_threshold):
542+
bootloader.install_image(image_path)
431543
# Take a backup of current configuration
432544
if skip_migration:
433545
echo_and_log("Skipping configuration migration as requested in the command option.")

tests/swap_allocator_test.py

+252
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import click
2+
import mock
3+
import pytest
4+
import pdb
5+
import subprocess
6+
7+
from sonic_installer.main import SWAPAllocator
8+
9+
10+
class TestSWAPAllocator(object):
11+
12+
@classmethod
13+
def setup(cls):
14+
print("SETUP")
15+
16+
def test_read_from_meminfo(self):
17+
proc_meminfo_lines = [
18+
"MemTotal: 32859496 kB",
19+
"MemFree: 16275512 kB",
20+
"HugePages_Total: 0",
21+
"HugePages_Free: 0",
22+
]
23+
24+
read_meminfo_expected_return = {
25+
"MemTotal": 32859496,
26+
"MemFree": 16275512,
27+
"HugePages_Total": 0,
28+
"HugePages_Free": 0
29+
}
30+
31+
with mock.patch("builtins.open") as mock_open:
32+
pseudo_fd = mock.MagicMock()
33+
pseudo_fd.readlines = mock.MagicMock(return_value=proc_meminfo_lines)
34+
mock_open.return_value.__enter__.return_value = pseudo_fd
35+
read_meminfo_actual_return = SWAPAllocator.read_from_meminfo()
36+
assert read_meminfo_actual_return == read_meminfo_expected_return
37+
38+
def test_setup_swapmem(self):
39+
with mock.patch("builtins.open") as mock_open, \
40+
mock.patch("os.posix_fallocate") as mock_fallocate, \
41+
mock.patch("os.chmod") as mock_chmod, \
42+
mock.patch("sonic_installer.main.run_command") as mock_run:
43+
pseudo_fd = mock.MagicMock()
44+
pseudo_fd_fileno = 10
45+
pseudo_fd.fileno.return_value = pseudo_fd_fileno
46+
mock_open.return_value.__enter__.return_value = pseudo_fd
47+
48+
swap_mem_size_in_mib = 2048 * 1024
49+
expected_swap_mem_size_in_bytes = swap_mem_size_in_mib * 1024 * 1024
50+
expected_swapfile_location = SWAPAllocator.SWAP_FILE_PATH
51+
expected_swapfile_permission = 0o600
52+
swap_allocator = SWAPAllocator(allocate=True, swap_mem_size=swap_mem_size_in_mib)
53+
swap_allocator.setup_swapmem()
54+
55+
mock_fallocate.assert_called_once_with(pseudo_fd_fileno, 0, expected_swap_mem_size_in_bytes)
56+
mock_chmod.assert_called_once_with(expected_swapfile_location, expected_swapfile_permission)
57+
mock_run.assert_called_once_with(f'mkswap {expected_swapfile_location}; swapon {expected_swapfile_location}')
58+
59+
def test_remove_swapmem(self):
60+
with mock.patch("subprocess.Popen") as mock_popen, \
61+
mock.patch("os.unlink") as mock_unlink:
62+
pseudo_subproc = mock.MagicMock()
63+
mock_popen.return_value = pseudo_subproc
64+
pseudo_subproc.communicate.return_value = ("swapoff: /home/swapfile: swapoff failed: No such file or directory", None)
65+
pseudo_subproc.returncode = 255
66+
67+
swap_allocator = SWAPAllocator(allocate=True)
68+
try:
69+
swap_allocator.remove_swapmem()
70+
except Exception as detail:
71+
pytest.fail("SWAPAllocator.remove_swapmem should not raise exception %s" % repr(detail))
72+
73+
expected_swapfile_location = SWAPAllocator.SWAP_FILE_PATH
74+
mock_popen.assert_called_once_with(['swapoff', expected_swapfile_location], stdout=subprocess.PIPE, text=True)
75+
mock_unlink.assert_called_once_with(SWAPAllocator.SWAP_FILE_PATH)
76+
77+
def test_swap_allocator_initialization_default_args(self):
78+
expected_allocate = False
79+
expected_swap_mem_size = SWAPAllocator.SWAP_MEM_SIZE
80+
expected_total_mem_threshold = SWAPAllocator.TOTAL_MEM_THRESHOLD
81+
expected_available_mem_threshold = SWAPAllocator.AVAILABLE_MEM_THRESHOLD
82+
swap_allocator = SWAPAllocator(allocate=expected_allocate)
83+
assert swap_allocator.allocate is expected_allocate
84+
assert swap_allocator.swap_mem_size == expected_swap_mem_size
85+
assert swap_allocator.total_mem_threshold == expected_total_mem_threshold
86+
assert swap_allocator.available_mem_threshold == expected_available_mem_threshold
87+
assert swap_allocator.is_allocated is False
88+
89+
def test_swap_allocator_initialization_custom_args(self):
90+
expected_allocate = True
91+
expected_swap_mem_size = 2048
92+
expected_total_mem_threshold = 4096
93+
expected_available_mem_threshold = 1024
94+
swap_allocator = SWAPAllocator(
95+
allocate=expected_allocate,
96+
swap_mem_size=expected_swap_mem_size,
97+
total_mem_threshold=expected_total_mem_threshold,
98+
available_mem_threshold=expected_available_mem_threshold
99+
)
100+
assert swap_allocator.allocate is expected_allocate
101+
assert swap_allocator.swap_mem_size == expected_swap_mem_size
102+
assert swap_allocator.total_mem_threshold == expected_total_mem_threshold
103+
assert swap_allocator.available_mem_threshold == expected_available_mem_threshold
104+
assert swap_allocator.is_allocated is False
105+
106+
def test_swap_allocator_context_enter_allocate_true_insufficient_total_memory(self):
107+
with mock.patch("sonic_installer.main.SWAPAllocator.get_disk_freespace") as mock_disk_free, \
108+
mock.patch("sonic_installer.main.SWAPAllocator.read_from_meminfo") as mock_meminfo, \
109+
mock.patch("sonic_installer.main.SWAPAllocator.setup_swapmem") as mock_setup, \
110+
mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove, \
111+
mock.patch("os.path.exists") as mock_exists:
112+
mock_disk_free.return_value = 10 * 1024 * 1024 * 1024
113+
mock_meminfo.return_value = {
114+
"MemTotal": 2000000,
115+
"MemAvailable": 1900000,
116+
}
117+
mock_exists.return_value = False
118+
119+
swap_allocator = SWAPAllocator(allocate=True)
120+
try:
121+
swap_allocator.__enter__()
122+
except Exception as detail:
123+
pytest.fail("SWAPAllocator context manager should not raise exception %s" % repr(detail))
124+
mock_setup.assert_called_once()
125+
mock_remove.assert_not_called()
126+
assert swap_allocator.is_allocated is True
127+
128+
def test_swap_allocator_context_enter_allocate_true_insufficient_available_memory(self):
129+
with mock.patch("sonic_installer.main.SWAPAllocator.get_disk_freespace") as mock_disk_free, \
130+
mock.patch("sonic_installer.main.SWAPAllocator.read_from_meminfo") as mock_meminfo, \
131+
mock.patch("sonic_installer.main.SWAPAllocator.setup_swapmem") as mock_setup, \
132+
mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove, \
133+
mock.patch("os.path.exists") as mock_exists:
134+
mock_disk_free.return_value = 10 * 1024 * 1024 * 1024
135+
mock_meminfo.return_value = {
136+
"MemTotal": 3000000,
137+
"MemAvailable": 1000000,
138+
}
139+
mock_exists.return_value = False
140+
141+
swap_allocator = SWAPAllocator(allocate=True)
142+
try:
143+
swap_allocator.__enter__()
144+
except Exception as detail:
145+
pytest.fail("SWAPAllocator context manager should not raise exception %s" % repr(detail))
146+
mock_setup.assert_called_once()
147+
mock_remove.assert_not_called()
148+
assert swap_allocator.is_allocated is True
149+
150+
def test_swap_allocator_context_enter_allocate_true_insufficient_disk_space(self):
151+
with mock.patch("sonic_installer.main.SWAPAllocator.get_disk_freespace") as mock_disk_free, \
152+
mock.patch("sonic_installer.main.SWAPAllocator.read_from_meminfo") as mock_meminfo, \
153+
mock.patch("sonic_installer.main.SWAPAllocator.setup_swapmem") as mock_setup, \
154+
mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove, \
155+
mock.patch("os.path.exists") as mock_exists:
156+
mock_disk_free.return_value = 1 * 1024 * 1024 * 1024
157+
mock_meminfo.return_value = {
158+
"MemTotal": 32859496,
159+
"MemAvailable": 16275512,
160+
}
161+
mock_exists.return_value = False
162+
163+
swap_allocator = SWAPAllocator(allocate=True)
164+
try:
165+
swap_allocator.__enter__()
166+
except Exception as detail:
167+
pytest.fail("SWAPAllocator context manager should not raise exception %s" % repr(detail))
168+
mock_setup.assert_not_called()
169+
mock_remove.assert_not_called()
170+
assert swap_allocator.is_allocated is False
171+
172+
def test_swap_allocator_context_enter_allocate_true_swapfile_present(self):
173+
with mock.patch("sonic_installer.main.SWAPAllocator.get_disk_freespace") as mock_disk_free, \
174+
mock.patch("sonic_installer.main.SWAPAllocator.read_from_meminfo") as mock_meminfo, \
175+
mock.patch("sonic_installer.main.SWAPAllocator.setup_swapmem") as mock_setup, \
176+
mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove, \
177+
mock.patch("os.path.exists") as mock_exists:
178+
mock_disk_free.return_value = 10 * 1024 * 1024 * 1024
179+
mock_meminfo.return_value = {
180+
"MemTotal": 32859496,
181+
"MemAvailable": 1000000,
182+
}
183+
mock_exists.return_value = True
184+
185+
swap_allocator = SWAPAllocator(allocate=True)
186+
try:
187+
swap_allocator.__enter__()
188+
except Exception as detail:
189+
pytest.fail("SWAPAllocator context manager should not raise exception %s" % repr(detail))
190+
mock_setup.assert_called_once()
191+
mock_remove.assert_called_once()
192+
assert swap_allocator.is_allocated is True
193+
194+
def test_swap_allocator_context_enter_setup_error(self):
195+
with mock.patch("sonic_installer.main.SWAPAllocator.get_disk_freespace") as mock_disk_free, \
196+
mock.patch("sonic_installer.main.SWAPAllocator.read_from_meminfo") as mock_meminfo, \
197+
mock.patch("sonic_installer.main.SWAPAllocator.setup_swapmem") as mock_setup, \
198+
mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove, \
199+
mock.patch("os.path.exists") as mock_exists:
200+
mock_disk_free.return_value = 10 * 1024 * 1024 * 1024
201+
mock_meminfo.return_value = {
202+
"MemTotal": 32859496,
203+
"MemAvailable": 1000000,
204+
}
205+
mock_exists.return_value = False
206+
expected_err_str = "Pseudo Error"
207+
mock_setup.side_effect = Exception(expected_err_str)
208+
209+
swap_allocator = SWAPAllocator(allocate=True)
210+
try:
211+
swap_allocator.__enter__()
212+
except Exception as detail:
213+
assert expected_err_str in str(detail)
214+
mock_setup.assert_called_once()
215+
mock_remove.assert_called_once()
216+
assert swap_allocator.is_allocated is False
217+
218+
def test_swap_allocator_context_enter_allocate_false(self):
219+
with mock.patch("sonic_installer.main.SWAPAllocator.get_disk_freespace") as mock_disk_free, \
220+
mock.patch("sonic_installer.main.SWAPAllocator.read_from_meminfo") as mock_meminfo, \
221+
mock.patch("sonic_installer.main.SWAPAllocator.setup_swapmem") as mock_setup, \
222+
mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove, \
223+
mock.patch("os.path.exists") as mock_exists:
224+
mock_disk_free.return_value = 10 * 1024 * 1024 * 1024
225+
mock_meminfo.return_value = {
226+
"MemTotal": 32859496,
227+
"MemAvailable": 1000000,
228+
}
229+
mock_exists.return_value = False
230+
231+
swap_allocator = SWAPAllocator(allocate=False)
232+
try:
233+
swap_allocator.__enter__()
234+
except Exception as detail:
235+
pytest.fail("SWAPAllocator context manager should not raise exception %s" % repr(detail))
236+
mock_setup.assert_not_called()
237+
mock_remove.assert_not_called()
238+
assert swap_allocator.is_allocated is False
239+
240+
def test_swap_allocator_context_exit_is_allocated_true(self):
241+
with mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove:
242+
swap_allocator = SWAPAllocator(allocate=True)
243+
swap_allocator.is_allocated = True
244+
swap_allocator.__exit__(None, None, None)
245+
mock_remove.assert_called_once()
246+
247+
def test_swap_allocator_context_exit_is_allocated_false(self):
248+
with mock.patch("sonic_installer.main.SWAPAllocator.remove_swapmem") as mock_remove:
249+
swap_allocator = SWAPAllocator(allocate=True)
250+
swap_allocator.is_allocated = False
251+
swap_allocator.__exit__(None, None, None)
252+
mock_remove.assert_not_called()

0 commit comments

Comments
 (0)