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:
parent
774ace58d8
commit
58eda809e6
|
@ -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
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue