Create seed iso for subcloud enrollment

Implement cloud-init nocloud seed iso generation, required to
reconfigure and prepare factory-installed nodes for the
enrollment process. The seed ISO contains the networking and user data,
it specifies the configuration applied upon booting the standalone system,
done as the initial step of the subcloud enrollment process.

Overall, this commit introduces the SubcloudEnrollment class with the
initial enrollment operations to:
   - Build the data files required
     to update the user and OAM network configuration
   - Generate the seed iso with the data files
   - Serve the iso for remote use

These will be utilized in subsequent changes where the
generated iso will be mounted to a factory installed node using RVMC.

Furthermore, these changes will be revisited when integrating with
the enrollment APIs to ensure that the payload is interrupted
correctly as iso_values from the provided
install-values + deployment config.

Test Plan:
1. PASS: Validate seed iso generation:
         Invoke generate_seed_iso with payload
         and ensure seed.iso is created at
         /opt/platform/iso/<rel-version>/nodes/<subcloud>
         Furthermore, ensure the iso is available and served
         by lighttpd at:
         /var/www/pages/iso/<rel-version>/nodes/<subcloud>

2. PASS: Validate iso regeneration:
         ensure previous seed iso is
         cleaned up and iso is regenerated.

3. PASS: Verify temp dirs are cleaned up

4. PASS: Validate contents of seed iso:
         Mount seed iso and ensure both
         network-config and cloud-config exists
         and they are correctly generated
         based on the payload.

Story: 2011100
Task: 50053

Change-Id: Icef8258852746aef2d0a3f025a1fe85fff93980e
Signed-off-by: Salman Rana <salman.rana@windriver.com>
This commit is contained in:
srana 2024-05-09 17:38:49 -04:00
parent 774ace58d8
commit 58eda809e6
2 changed files with 272 additions and 0 deletions

View File

@ -0,0 +1,154 @@
# Copyright (c) 2024 Wind River Systems, 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.
#
import hashlib
import os
import shutil
import tempfile
import yaml
from eventlet.green import subprocess
from oslo_config import cfg
from oslo_log import log as logging
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
SUBCLOUD_ISO_PATH = '/opt/platform/iso'
class SubcloudEnrollment(object):
"""Class to encapsulate the subcloud enrollment operations"""
def __init__(self, subcloud_name):
self.name = subcloud_name
self.www_root = None
self.iso_dir_path = None
self.seed_iso_path = None
def build_seed_network_config(self, path, iso_values):
if not os.path.isdir(path):
raise Exception(f'No folder exists: {path}')
# TODO(srana): Investigate other bootstrap / install values
# that would need to be covered here.
network_cloud_config = [
{
'type': 'physical',
'name': iso_values['bootstrap_interface'],
'subnets': [
{
'type': 'static',
'address': iso_values['external_oam_floating_address'],
'netmask': iso_values['network_mask'],
'gateway': iso_values['external_oam_gateway_address'],
}
]
}
]
network_config_file = os.path.join(path, 'network-config')
with open(network_config_file, 'w') as f_out_network_config_file:
contents = {'version': 1, 'config': network_cloud_config}
f_out_network_config_file.write(yaml.dump(contents,
default_flow_style=False,
sort_keys=False))
return True
def build_seed_user_config(self, path, iso_values):
if not os.path.isdir(path):
raise Exception(f'No folder exists: {path}')
hashed_password = hashlib.sha256(
iso_values['admin_password'].encode()).hexdigest()
account_config = {
'list': [f'sysadmin:{hashed_password}'],
'expire': 'False'
}
user_data_file = os.path.join(path, 'user-data')
with open(user_data_file, 'w') as f_out_user_data_file:
contents = {'chpasswd': account_config}
f_out_user_data_file.writelines('#cloud-config\n')
f_out_user_data_file.write(yaml.dump(contents,
default_flow_style=False,
sort_keys=False))
return True
def generate_seed_iso(self, payload):
self.www_root = os.path.join(SUBCLOUD_ISO_PATH, payload['software_version'])
temp_seed_data_dir = tempfile.mkdtemp(prefix='seed_')
self.iso_dir_path = os.path.join(self.www_root, 'nodes', self.name)
self.seed_iso_path = os.path.join(self.iso_dir_path, 'seed.iso')
LOG.info('Prepare for seed iso generation')
if not os.path.isdir(self.iso_dir_path):
os.makedirs(self.iso_dir_path, 0o755, exist_ok=True)
elif os.path.exists(self.seed_iso_path):
LOG.info(f'Found preexisting seed iso for subcloud {self.name}, '
f'cleaning up')
os.remove(self.seed_iso_path)
# TODO(srana): After integration, extract required bootstrap and install
# into iso_values. For now, pass in payload.
try:
# Generate seed cloud-config files
self.build_seed_network_config(temp_seed_data_dir, payload)
self.build_seed_user_config(temp_seed_data_dir, payload)
except Exception as e:
shutil.rmtree(temp_seed_data_dir)
LOG.exception(f'Unable to generate seed config files: {e}')
return False
# Trigger generation of seed iso
gen_seed_iso_command = [
"genisoimage",
"-o", self.seed_iso_path,
"-volid", "CIDATA",
"-untranslated-filenames",
"-joliet",
"-rock",
"-iso-level", "2",
temp_seed_data_dir
]
LOG.info(f'Running gen_seed_iso_command: {gen_seed_iso_command}')
result = subprocess.run(gen_seed_iso_command,
# capture both streams in stdout:
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
shutil.rmtree(temp_seed_data_dir)
if result.returncode == 0:
msg = f'Finished generating seed iso: {gen_seed_iso_command}'
LOG.info("%s returncode: %s, output: %s",
msg,
result.returncode,
result.stdout.decode('utf-8').replace('\n', ', '))
else:
msg = f'Failed to generate seed iso: {gen_seed_iso_command}'
LOG.error("%s returncode: %s, output: %s",
msg,
result.returncode,
result.stdout.decode('utf-8').replace('\n', ', '))
raise Exception(msg)
return True

View File

@ -40,6 +40,7 @@ from dccommon import consts as dccommon_consts
from dccommon.drivers.openstack import dcmanager_v1
from dccommon.exceptions import PlaybookExecutionFailed
from dccommon import kubeoperator
from dccommon import subcloud_enrollment
from dccommon import subcloud_install
from dccommon.utils import AnsiblePlaybook
from dcmanager.common import consts
@ -4053,3 +4054,120 @@ class TestSubcloudRename(BaseTestSubcloudManager):
)
self.assertEqual(self.new_subcloud_name, ret.name)
self.assertEqual(self.mock_os_listdir.call_count, 2)
class TestSubcloudEnrollment(BaseTestSubcloudManager):
"""Test class for testing Subcloud Enrollment"""
def setUp(self):
super().setUp()
self.rel_version = '24.09'
self.subcloud_name = 'test_subcloud'
self.iso_dir = (f'/opt/platform/iso/{self.rel_version}/'
f'nodes/{self.subcloud_name}')
self.iso_file = f'{self.iso_dir}/seed.iso'
self.seed_data_dir = '/temp/seed_data'
self.enroll = subcloud_enrollment.SubcloudEnrollment(self.subcloud_name)
self.iso_values = {
'software_version': self.rel_version,
'admin_password': 'St8rlingX*',
'bootstrap_interface': 'enp2s1',
'external_oam_floating_address': '10.10.10.2',
'network_mask': '255.255.255.0',
'external_oam_gateway_address': '10.10.10.1',
}
mock_run_patch_patch = mock.patch('eventlet.green.subprocess.run')
mock_mkdtemp_patch = mock.patch('tempfile.mkdtemp')
mock_makedirs_patch = mock.patch('os.makedirs')
mock_rmtree_patch = mock.patch('shutil.rmtree')
self.mock_run = mock_run_patch_patch.start()
self.mock_mkdtemp = mock_mkdtemp_patch.start()
self.mock_makedirs = mock_makedirs_patch.start()
self.mock_rmtree = mock_rmtree_patch.start()
self.addCleanup(mock_run_patch_patch.stop)
self.addCleanup(mock_mkdtemp_patch.stop)
self.addCleanup(mock_makedirs_patch.stop)
self.addCleanup(mock_rmtree_patch.stop)
self._mock_builtins_open()
self.mock_builtins_open.side_effect = mock.mock_open()
self.mock_os_path_exists.return_value = True
self.mock_mkdtemp.return_value = self.seed_data_dir
self.mock_os_path_isdir.return_value = True
self.mock_run.return_value = mock.MagicMock(returncode=0,
stdout=b'Success')
def patched_isdir(self, path):
return path != self.iso_dir
def test_build_seed_network_config(self):
result = self.enroll.build_seed_network_config(self.seed_data_dir,
self.iso_values)
self.assertTrue(result)
self.mock_builtins_open.assert_called_once_with(
f'{self.seed_data_dir}/network-config',
'w')
# Test with incomplete iso_values, expect KeyError
copied_dict = self.iso_values.copy()
copied_dict.pop('external_oam_floating_address')
test_func = lambda: self.enroll.build_seed_network_config(
self.seed_data_dir,
copied_dict)
self.assertRaises(KeyError, test_func)
def test_build_seed_user_config(self):
result = self.enroll.build_seed_user_config(self.seed_data_dir,
self.iso_values)
self.assertTrue(result)
self.mock_builtins_open.assert_called_once_with(
f'{self.seed_data_dir}/user-data',
'w')
# Test with incomplete iso_values, expect KeyError
copied_dict = self.iso_values.copy()
copied_dict.pop('admin_password')
test_func = lambda: self.enroll.build_seed_user_config(
self.seed_data_dir,
copied_dict)
self.assertRaises(KeyError, test_func)
def test_generate_seed_iso(self):
with mock.patch('os.path.isdir', side_effect=self.patched_isdir):
self.assertTrue(self.enroll.generate_seed_iso(self.iso_values))
# Iso command must be invoked (subprocess.run)
self.mock_run.assert_called_once()
# Temp seed data dir must be cleaned up
self.mock_rmtree.assert_called_once_with(self.seed_data_dir)
# Iso dir must be created
self.mock_makedirs.assert_called_once()
self.assertTrue(self.mock_makedirs.call_args.args[0] == self.iso_dir)
# Seed files must be generted in temp seed dir
self.mock_builtins_open.assert_any_call(
f'{self.seed_data_dir}/network-config',
'w')
self.mock_builtins_open.assert_any_call(
f'{self.seed_data_dir}/user-data',
'w')
def test_generate_seed_iso_pre_exisiting_iso(self):
self.assertTrue(self.enroll.generate_seed_iso(self.iso_values))
# Previous iso file must be cleaned up
self.mock_os_remove.assert_called_once_with(self.iso_file)
# Makedirs shouldn't be invoked, given that prev iso exisited
self.mock_makedirs.assert_not_called()
# Iso command must be invoked (subprocess.run)
self.mock_run.assert_called_once()
# Temp seed data dir must be cleaned up
self.mock_rmtree.assert_called_once_with(self.seed_data_dir)