generate-all-test-cases: use SSH keys instead of password for VMs
Previously passwords were used to log into provisioned QEMU VMs. This is not practical if one would like to use e.g. rsync to transfer files from and to the VM. The script now does not use passwords at all, but instead configures the most recent SSH key from the system matching '~/.ssh/id*.pub' as an authorized key on the VM. Alternatively the SSH key to be used can be provided as an argument to the script. In addition, the script no longer relies on external files for cloud-init user-data. If no cloud-init user-data are provided as an argument to the script creates default user-data file in a temporary work directory and uses it. The reason for this change is mostly that the default user-data became very short and need to be always extended with the authorized SSH key anyway. In addition, this makes the script more standalone by relying on fewer external file paths. Delete the `tools/deploy/gen-test-data` which held the cloud-init user-data previously used by default by the script. Signed-off-by: Tomas Hozza <thozza@redhat.com>
This commit is contained in:
parent
6f89cada2d
commit
94c2a6268c
2 changed files with 121 additions and 47 deletions
|
|
@ -1,18 +0,0 @@
|
||||||
#cloud-config
|
|
||||||
yum_repos:
|
|
||||||
# Fetch osbuild packages from a repository served on the host.
|
|
||||||
#
|
|
||||||
# In qemu user networking, 10.0.2.2 always points to the host:
|
|
||||||
# https://wiki.qemu.org/Documentation/Networking#User_Networking_.28SLIRP.29
|
|
||||||
osbuild:
|
|
||||||
name: osbuild
|
|
||||||
baseurl: "http://10.0.2.2:8000"
|
|
||||||
enabled: true
|
|
||||||
gpgcheck: false
|
|
||||||
skip_if_unavailable: true
|
|
||||||
user: admin
|
|
||||||
password: foobar
|
|
||||||
ssh_pwauth: True
|
|
||||||
chpasswd:
|
|
||||||
expire: False
|
|
||||||
sudo: 'ALL=(ALL) NOPASSWD:ALL'
|
|
||||||
|
|
@ -80,6 +80,7 @@ import socket
|
||||||
import contextlib
|
import contextlib
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
|
import glob
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
@ -133,11 +134,10 @@ class BaseRunner(contextlib.AbstractContextManager):
|
||||||
test case definitions.
|
test case definitions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hostname, username="root", password=None, port=22):
|
def __init__(self, hostname, username="root", port=22):
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.port = port
|
self.port = port
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
|
||||||
self.runner_ready = False
|
self.runner_ready = False
|
||||||
|
|
||||||
def run_command(self, command):
|
def run_command(self, command):
|
||||||
|
|
@ -153,7 +153,7 @@ class BaseRunner(contextlib.AbstractContextManager):
|
||||||
# don't ask / fail on unknown remote host fingerprint, just accept any
|
# don't ask / fail on unknown remote host fingerprint, just accept any
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
try:
|
try:
|
||||||
ssh.connect(self.hostname, self.port, self.username, self.password)
|
ssh.connect(self.hostname, self.port, self.username)
|
||||||
ssh_tansport = ssh.get_transport()
|
ssh_tansport = ssh.get_transport()
|
||||||
channel = ssh_tansport.open_session()
|
channel = ssh_tansport.open_session()
|
||||||
# don't log commands when the vm is not yet ready for use
|
# don't log commands when the vm is not yet ready for use
|
||||||
|
|
@ -245,8 +245,13 @@ class BaseQEMURunner(BaseRunner):
|
||||||
# the actual command to use for running QEMU VM
|
# the actual command to use for running QEMU VM
|
||||||
QEMU_CMD = None
|
QEMU_CMD = None
|
||||||
|
|
||||||
def __init__(self, image, username, password, cdrom_iso=None, mount_points=None):
|
DEFAULT_CI_USER_DATA = {
|
||||||
super().__init__("localhost", username=username, password=password)
|
"user": "admin",
|
||||||
|
"sudo": "ALL=(ALL) NOPASSWD:ALL"
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, image, username, cdrom_iso=None, mount_points=None):
|
||||||
|
super().__init__("localhost", username)
|
||||||
self._check_qemu_bin()
|
self._check_qemu_bin()
|
||||||
|
|
||||||
# path to image to run
|
# path to image to run
|
||||||
|
|
@ -348,10 +353,9 @@ class BaseQEMURunner(BaseRunner):
|
||||||
self._run_qemu_cmd(list(self.QEMU_CMD))
|
self._run_qemu_cmd(list(self.QEMU_CMD))
|
||||||
log.info(
|
log.info(
|
||||||
"Runner started. You can SSH to it once it has been configured:" + \
|
"Runner started. You can SSH to it once it has been configured:" + \
|
||||||
"'ssh %s@localhost -p %d' using password: '%s'",
|
"'ssh %s@localhost -p %d'",
|
||||||
self.username,
|
self.username,
|
||||||
self.port,
|
self.port
|
||||||
self.password
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|
@ -434,12 +438,40 @@ class BaseQEMURunner(BaseRunner):
|
||||||
def __exit__(self, *exc_details):
|
def __exit__(self, *exc_details):
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_default_ci_userdata(cls, workdir):
|
||||||
|
"""
|
||||||
|
Creates the default 'user-data.yml' file for cloud-init inside the
|
||||||
|
'workdir'. The path of the created file is returned.
|
||||||
|
"""
|
||||||
|
default_ci_userdata_path = f"{workdir}/user-data.yml"
|
||||||
|
|
||||||
|
with open(default_ci_userdata_path, "w") as f:
|
||||||
|
f.write("#cloud-config\n")
|
||||||
|
yaml.safe_dump(cls.DEFAULT_CI_USER_DATA, f)
|
||||||
|
|
||||||
|
return default_ci_userdata_path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def prepare_cloud_init_cdrom(userdata, workdir):
|
def ci_userdata_add_authorized_ssh_key(userdata_file, ssh_id_file):
|
||||||
|
"""
|
||||||
|
Modifies the provided 'userdata_file' in-place by appending the provided
|
||||||
|
'ssh_id_file' as authorized SSH key to it.
|
||||||
|
"""
|
||||||
|
append_data = {}
|
||||||
|
with open(ssh_id_file, encoding="utf-8") as f:
|
||||||
|
append_data["ssh_authorized_keys"] = [f.read().strip()]
|
||||||
|
|
||||||
|
with open(userdata_file, "a") as f:
|
||||||
|
yaml.safe_dump(append_data, f, width=float("inf"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prepare_cloud_init_cdrom(ssh_id_file, workdir, userdata=None):
|
||||||
"""
|
"""
|
||||||
Generates a CDROM ISO used as a data source for cloud-init.
|
Generates a CDROM ISO used as a data source for cloud-init.
|
||||||
|
|
||||||
Returns path to the generated CDROM ISO image.
|
Returns path to the generated CDROM ISO image and path to the used
|
||||||
|
cloud-init userdata.
|
||||||
"""
|
"""
|
||||||
iso_path = os.path.join(workdir, "cloudinit.iso")
|
iso_path = os.path.join(workdir, "cloudinit.iso")
|
||||||
cidatadir = os.path.join(workdir, "cidata")
|
cidatadir = os.path.join(workdir, "cidata")
|
||||||
|
|
@ -447,13 +479,31 @@ class BaseQEMURunner(BaseRunner):
|
||||||
meta_data_path = os.path.join(cidatadir, "meta-data")
|
meta_data_path = os.path.join(cidatadir, "meta-data")
|
||||||
|
|
||||||
os.mkdir(cidatadir)
|
os.mkdir(cidatadir)
|
||||||
|
|
||||||
|
# If no userdata were provided, use the default one
|
||||||
|
if not userdata:
|
||||||
|
userdata = BaseQEMURunner.create_default_ci_userdata(workdir)
|
||||||
|
log.debug("Using default cloud-init user-data created at: %s", userdata)
|
||||||
|
|
||||||
if os.path.isdir(userdata):
|
if os.path.isdir(userdata):
|
||||||
|
# create a copy of the provided userdata, since they will be modified
|
||||||
|
userdata_tmp_dir = f"{workdir}/ci_userdata_copy"
|
||||||
|
os.makedirs(userdata_tmp_dir)
|
||||||
|
shutil.copytree(userdata, userdata_tmp_dir)
|
||||||
|
userdata = userdata_tmp_dir
|
||||||
|
|
||||||
|
# Add the ssh key to the user-data
|
||||||
|
userdata_file = f"{userdata}/user-data.yml"
|
||||||
|
BaseQEMURunner.ci_userdata_add_authorized_ssh_key(userdata_file, ssh_id_file)
|
||||||
|
|
||||||
with open(user_data_path, "w") as f:
|
with open(user_data_path, "w") as f:
|
||||||
script_dir = os.path.dirname(__file__)
|
script_dir = os.path.dirname(__file__)
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
[os.path.abspath(f"{script_dir}/../gen-user-data"), userdata], stdout=f)
|
[os.path.abspath(f"{script_dir}/../gen-user-data"), userdata], stdout=f)
|
||||||
else:
|
else:
|
||||||
shutil.copy(userdata, user_data_path)
|
shutil.copy(userdata, user_data_path)
|
||||||
|
# Add the ssh key to the user-data
|
||||||
|
BaseQEMURunner.ci_userdata_add_authorized_ssh_key(user_data_path, ssh_id_file)
|
||||||
|
|
||||||
with open(meta_data_path, "w") as f:
|
with open(meta_data_path, "w") as f:
|
||||||
f.write("instance-id: nocloud\nlocal-hostname: vm\n")
|
f.write("instance-id: nocloud\nlocal-hostname: vm\n")
|
||||||
|
|
@ -493,7 +543,7 @@ class BaseQEMURunner(BaseRunner):
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"Unsupported system '{sysname}' for generating cdrom iso")
|
raise NotImplementedError(f"Unsupported system '{sysname}' for generating cdrom iso")
|
||||||
|
|
||||||
return iso_path
|
return iso_path, userdata
|
||||||
|
|
||||||
|
|
||||||
class X86_64QEMURunner(BaseQEMURunner):
|
class X86_64QEMURunner(BaseQEMURunner):
|
||||||
|
|
@ -632,7 +682,7 @@ class TestCaseMatrixGenerator(contextlib.AbstractContextManager):
|
||||||
"python3-pyyaml" # needed by image-info
|
"python3-pyyaml" # needed by image-info
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, images, ci_userdata, arch_gen_matrix, output, keep_image_info):
|
def __init__(self, images, arch_gen_matrix, output, keep_image_info, ssh_id_file, ci_userdata=None):
|
||||||
"""
|
"""
|
||||||
'images' is a dict of qcow2 image paths for each supported architecture,
|
'images' is a dict of qcow2 image paths for each supported architecture,
|
||||||
that should be used for VMs:
|
that should be used for VMs:
|
||||||
|
|
@ -641,8 +691,6 @@ class TestCaseMatrixGenerator(contextlib.AbstractContextManager):
|
||||||
"arch2": "<image path>",
|
"arch2": "<image path>",
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
'ci_userdata' is path to file / directory containing cloud-init user-data used
|
|
||||||
for generating CDROM ISO image, that is attached to each VM as a cloud-init data source.
|
|
||||||
'arch_get_matrix' is a dict of requested distro-image_type matrix per architecture:
|
'arch_get_matrix' is a dict of requested distro-image_type matrix per architecture:
|
||||||
{
|
{
|
||||||
"arch1": {
|
"arch1": {
|
||||||
|
|
@ -664,13 +712,18 @@ class TestCaseMatrixGenerator(contextlib.AbstractContextManager):
|
||||||
}
|
}
|
||||||
'output' is a directory path, where the generated test case manifests should be stored.
|
'output' is a directory path, where the generated test case manifests should be stored.
|
||||||
'keep_image_info' specifies whether to pass the '--keep-image-info' option to the 'generate-test-cases' script.
|
'keep_image_info' specifies whether to pass the '--keep-image-info' option to the 'generate-test-cases' script.
|
||||||
|
'ssh_id_file' is path to the SSH ID file to use as the authorized key for the QEMU VMs.
|
||||||
|
'ci_userdata' is path to file / directory containing cloud-init user-data used
|
||||||
|
for generating CDROM ISO image, that is attached to each VM as a cloud-init data source.
|
||||||
|
If the value is not provided, then the default internal cloud-init user-data are used.
|
||||||
"""
|
"""
|
||||||
self._processes = list()
|
self._processes = list()
|
||||||
self.images = images
|
self.images = images
|
||||||
self.ci_userdata = ci_userdata
|
|
||||||
self.arch_gen_matrix = arch_gen_matrix
|
self.arch_gen_matrix = arch_gen_matrix
|
||||||
self.output = output
|
self.output = output
|
||||||
self.keep_image_info = keep_image_info
|
self.keep_image_info = keep_image_info
|
||||||
|
self.ssh_id_file = ssh_id_file
|
||||||
|
self.ci_userdata = ci_userdata
|
||||||
|
|
||||||
# check that we have image for each needed architecture
|
# check that we have image for each needed architecture
|
||||||
for arch in self.arch_gen_matrix.keys():
|
for arch in self.arch_gen_matrix.keys():
|
||||||
|
|
@ -678,7 +731,7 @@ class TestCaseMatrixGenerator(contextlib.AbstractContextManager):
|
||||||
raise RuntimeError(f"architecture '{arch}' is in requested test matrix, but no image was provided")
|
raise RuntimeError(f"architecture '{arch}' is in requested test matrix, but no image was provided")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def runner_function(arch, runner_cls, image, user, passwd, cdrom_iso, generation_matrix, output, keep_image_info):
|
def runner_function(arch, runner_cls, image, user, cdrom_iso, generation_matrix, output, keep_image_info):
|
||||||
"""
|
"""
|
||||||
Generate test cases using VM with appropriate architecture.
|
Generate test cases using VM with appropriate architecture.
|
||||||
|
|
||||||
|
|
@ -702,7 +755,7 @@ class TestCaseMatrixGenerator(contextlib.AbstractContextManager):
|
||||||
go_tls_timeout_retries = 3
|
go_tls_timeout_retries = 3
|
||||||
|
|
||||||
# spin up appropriate VM represented by 'runner'
|
# spin up appropriate VM represented by 'runner'
|
||||||
with runner_cls(image, user, passwd, cdrom_iso, mount_points=mount_points) as runner:
|
with runner_cls(image, user, cdrom_iso, mount_points=mount_points) as runner:
|
||||||
log.info("Waiting for the '%s' runner to be configured by cloud-init", arch)
|
log.info("Waiting for the '%s' runner to be configured by cloud-init", arch)
|
||||||
runner.wait_until_ready()
|
runner.wait_until_ready()
|
||||||
runner.mount_mount_points()
|
runner.mount_mount_points()
|
||||||
|
|
@ -788,23 +841,24 @@ class TestCaseMatrixGenerator(contextlib.AbstractContextManager):
|
||||||
"""
|
"""
|
||||||
# use the same CDROM ISO image for all VMs
|
# use the same CDROM ISO image for all VMs
|
||||||
with tempfile.TemporaryDirectory(prefix="osbuild-composer-test-gen-") as tmpdir:
|
with tempfile.TemporaryDirectory(prefix="osbuild-composer-test-gen-") as tmpdir:
|
||||||
cdrom_iso = BaseQEMURunner.prepare_cloud_init_cdrom(self.ci_userdata, tmpdir)
|
cdrom_iso, used_userdata = BaseQEMURunner.prepare_cloud_init_cdrom(
|
||||||
|
self.ssh_id_file, tmpdir, self.ci_userdata
|
||||||
|
)
|
||||||
|
|
||||||
# Load user / password from the cloud-init user-data
|
# Load user from the cloud-init user-data
|
||||||
if os.path.isdir(self.ci_userdata):
|
if os.path.isdir(used_userdata):
|
||||||
user_data_path = os.path.join(self.ci_userdata, "user-data.yml")
|
user_data_path = f"{used_userdata}/user-data.yml"
|
||||||
else:
|
else:
|
||||||
user_data_path = self.ci_userdata
|
user_data_path = used_userdata
|
||||||
with open(user_data_path, "r") as ud:
|
with open(user_data_path, "r") as ud:
|
||||||
user_data = yaml.safe_load(ud)
|
user_data = yaml.safe_load(ud)
|
||||||
vm_user = user_data["user"]
|
vm_user = user_data["user"]
|
||||||
vm_pass = user_data["password"]
|
|
||||||
|
|
||||||
# Start a separate runner VM for each required architecture
|
# Start a separate runner VM for each required architecture
|
||||||
for arch, generation_matrix in self.arch_gen_matrix.items():
|
for arch, generation_matrix in self.arch_gen_matrix.items():
|
||||||
process = multiprocessing.Process(
|
process = multiprocessing.Process(
|
||||||
target=self.runner_function,
|
target=self.runner_function,
|
||||||
args=(arch, self.ARCH_RUNNER_MAP[arch], self.images[arch], vm_user, vm_pass, cdrom_iso,
|
args=(arch, self.ARCH_RUNNER_MAP[arch], self.images[arch], vm_user, cdrom_iso,
|
||||||
generation_matrix, self.output, self.keep_image_info))
|
generation_matrix, self.output, self.keep_image_info))
|
||||||
self._processes.append(process)
|
self._processes.append(process)
|
||||||
process.start()
|
process.start()
|
||||||
|
|
@ -834,6 +888,32 @@ class TestCaseMatrixGenerator(contextlib.AbstractContextManager):
|
||||||
self.cleanup()
|
self.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_ssh_id_file():
|
||||||
|
"""
|
||||||
|
Returns the path of the default SSH ID file to use.
|
||||||
|
|
||||||
|
The defalt SSH ID file is the most recent file that matches: ~/.ssh/id*.pub,
|
||||||
|
(excluding those that match ~/.ssh/*-cert.pub). This mimics the bahaviour
|
||||||
|
of the 'ssh-copy-id' command.
|
||||||
|
"""
|
||||||
|
id_files = glob.glob(os.path.expanduser("~/.ssh/id*.pub"))
|
||||||
|
id_files = [f for f in id_files if not f.endswith("-cert.pub")]
|
||||||
|
|
||||||
|
try:
|
||||||
|
most_recent_file = id_files[0]
|
||||||
|
except IndexError as e:
|
||||||
|
raise RuntimeError("Found no files matching '~/.ssh/id*.pub'") from e
|
||||||
|
most_recent_file_mtime = os.path.getmtime(most_recent_file)
|
||||||
|
|
||||||
|
for id_file in id_files[1:]:
|
||||||
|
id_file_mtime = os.path.getmtime(id_file)
|
||||||
|
if most_recent_file_mtime < id_file_mtime:
|
||||||
|
most_recent_file = id_file
|
||||||
|
most_recent_file_mtime = id_file_mtime
|
||||||
|
|
||||||
|
return most_recent_file
|
||||||
|
|
||||||
|
|
||||||
def get_args():
|
def get_args():
|
||||||
"""
|
"""
|
||||||
Returns ArgumentParser instance specific to this script.
|
Returns ArgumentParser instance specific to this script.
|
||||||
|
|
@ -900,6 +980,12 @@ def get_args():
|
||||||
help="Path to a file/directory with cloud-init user-data, which should be used to configure runner VMs",
|
help="Path to a file/directory with cloud-init user-data, which should be used to configure runner VMs",
|
||||||
type=os.path.abspath
|
type=os.path.abspath
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-i", "--ssh-id-file",
|
||||||
|
help="Path to the SSH ID file to use for authenticating to the runner VMs. If the file does not end with " + \
|
||||||
|
".pub, it will be appended to it.",
|
||||||
|
type=os.path.abspath
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-d", "--debug",
|
"-d", "--debug",
|
||||||
action='store_true',
|
action='store_true',
|
||||||
|
|
@ -909,7 +995,7 @@ def get_args():
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments,too-many-locals
|
# pylint: disable=too-many-arguments,too-many-locals
|
||||||
def main(vm_images, distros, arches, image_types, ci_userdata, gen_matrix_file, output, keep_image_info):
|
def main(vm_images, distros, arches, image_types, ssh_id_file, ci_userdata, gen_matrix_file, output, keep_image_info):
|
||||||
if not os.path.isdir(output):
|
if not os.path.isdir(output):
|
||||||
raise RuntimeError(f"output directory {output} does not exist")
|
raise RuntimeError(f"output directory {output} does not exist")
|
||||||
|
|
||||||
|
|
@ -951,10 +1037,15 @@ def main(vm_images, distros, arches, image_types, ci_userdata, gen_matrix_file,
|
||||||
|
|
||||||
log.debug("arch_gen_matrix_dict:\n%s", json.dumps(arch_gen_matrix_dict, indent=2, sort_keys=True))
|
log.debug("arch_gen_matrix_dict:\n%s", json.dumps(arch_gen_matrix_dict, indent=2, sort_keys=True))
|
||||||
|
|
||||||
ci_userdata_path = ci_userdata if ci_userdata else os.path.abspath(f"{script_dir}/../deploy/gen-test-data")
|
# determine the SSH ID file to be used
|
||||||
log.debug("Using cloud-init user-data from '%s'", ci_userdata_path)
|
ssh_id_file = args.ssh_id_file
|
||||||
|
if not ssh_id_file:
|
||||||
|
ssh_id_file = get_default_ssh_id_file()
|
||||||
|
if not ssh_id_file.endswith(".pub"):
|
||||||
|
ssh_id_file += ".pub"
|
||||||
|
log.debug("Using SSH ID file: %s", ssh_id_file)
|
||||||
|
|
||||||
with TestCaseMatrixGenerator(vm_images, ci_userdata_path, arch_gen_matrix_dict, output, keep_image_info) as generator:
|
with TestCaseMatrixGenerator(vm_images, arch_gen_matrix_dict, output, keep_image_info, ssh_id_file, ci_userdata) as generator:
|
||||||
generator.generate()
|
generator.generate()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -977,6 +1068,7 @@ if __name__ == '__main__':
|
||||||
args.distro,
|
args.distro,
|
||||||
args.arch,
|
args.arch,
|
||||||
args.image_types,
|
args.image_types,
|
||||||
|
args.ssh_id_file,
|
||||||
args.ci_userdata,
|
args.ci_userdata,
|
||||||
args.gen_matrix_file,
|
args.gen_matrix_file,
|
||||||
args.output,
|
args.output,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue