dts: add config parser

The configuration is split into two parts, one defining the parameters
of the test run and the other defining the topology to be used.

The format of the configuration is YAML. It is validated according to a
json schema which also server as detailed documentation of the various
configuration fields. This means that the complete set of allowed values
are tied to the schema as a source of truth. This enables making changes
to parts of DTS that interface with config files without a high risk of
breaking someone's configuration.

This configuration system uses immutable objects to represent the
configuration, making IDE/LSP autocomplete work properly.

There are two ways to specify the configuration file path, an
environment variable or a command line argument, applied in that order.

Signed-off-by: Owen Hilyard <ohilyard@iol.unh.edu>
Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
This commit is contained in:
Owen Hilyard 2022-11-04 11:05:17 +00:00 committed by Thomas Monjalon
parent 724b8a37be
commit 995fb3372e
5 changed files with 260 additions and 0 deletions

9
dts/conf.yaml Normal file
View File

@ -0,0 +1,9 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright 2022 The DPDK contributors
executions:
- system_under_test: "SUT 1"
nodes:
- name: "SUT 1"
hostname: sut1.change.me.localhost
user: root

View File

@ -0,0 +1,3 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright(c) 2022 PANTHEON.tech s.r.o.
# Copyright(c) 2022 University of New Hampshire

View File

@ -0,0 +1,99 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright(c) 2010-2021 Intel Corporation
# Copyright(c) 2022 University of New Hampshire
"""
Generic port and topology nodes configuration file load function
"""
import json
import os.path
import pathlib
from dataclasses import dataclass
from typing import Any
import warlock # type: ignore
import yaml
from framework.settings import SETTINGS
# Slots enables some optimizations, by pre-allocating space for the defined
# attributes in the underlying data structure.
#
# Frozen makes the object immutable. This enables further optimizations,
# and makes it thread safe should we every want to move in that direction.
@dataclass(slots=True, frozen=True)
class NodeConfiguration:
name: str
hostname: str
user: str
password: str | None
@staticmethod
def from_dict(d: dict) -> "NodeConfiguration":
return NodeConfiguration(
name=d["name"],
hostname=d["hostname"],
user=d["user"],
password=d.get("password"),
)
@dataclass(slots=True, frozen=True)
class ExecutionConfiguration:
system_under_test: NodeConfiguration
@staticmethod
def from_dict(d: dict, node_map: dict) -> "ExecutionConfiguration":
sut_name = d["system_under_test"]
assert sut_name in node_map, f"Unknown SUT {sut_name} in execution {d}"
return ExecutionConfiguration(
system_under_test=node_map[sut_name],
)
@dataclass(slots=True, frozen=True)
class Configuration:
executions: list[ExecutionConfiguration]
@staticmethod
def from_dict(d: dict) -> "Configuration":
nodes: list[NodeConfiguration] = list(
map(NodeConfiguration.from_dict, d["nodes"])
)
assert len(nodes) > 0, "There must be a node to test"
node_map = {node.name: node for node in nodes}
assert len(nodes) == len(node_map), "Duplicate node names are not allowed"
executions: list[ExecutionConfiguration] = list(
map(
ExecutionConfiguration.from_dict, d["executions"], [node_map for _ in d]
)
)
return Configuration(executions=executions)
def load_config() -> Configuration:
"""
Loads the configuration file and the configuration file schema,
validates the configuration file, and creates a configuration object.
"""
with open(SETTINGS.config_file_path, "r") as f:
config_data = yaml.safe_load(f)
schema_path = os.path.join(
pathlib.Path(__file__).parent.resolve(), "conf_yaml_schema.json"
)
with open(schema_path, "r") as f:
schema = json.load(f)
config: dict[str, Any] = warlock.model_factory(schema, name="_Config")(config_data)
config_obj: Configuration = Configuration.from_dict(dict(config))
return config_obj
CONFIGURATION = load_config()

View File

@ -0,0 +1,65 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "DTS Config Schema",
"definitions": {
"node_name": {
"type": "string",
"description": "A unique identifier for a node"
}
},
"type": "object",
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "A unique identifier for this node"
},
"hostname": {
"type": "string",
"description": "A hostname from which the node running DTS can access this node. This can also be an IP address."
},
"user": {
"type": "string",
"description": "The user to access this node with."
},
"password": {
"type": "string",
"description": "The password to use on this node. Use only as a last resort. SSH keys are STRONGLY preferred."
}
},
"additionalProperties": false,
"required": [
"name",
"hostname",
"user"
]
},
"minimum": 1
},
"executions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"system_under_test": {
"$ref": "#/definitions/node_name"
}
},
"additionalProperties": false,
"required": [
"system_under_test"
]
},
"minimum": 1
}
},
"required": [
"executions",
"nodes"
],
"additionalProperties": false
}

84
dts/framework/settings.py Normal file
View File

@ -0,0 +1,84 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright(c) 2010-2021 Intel Corporation
# Copyright(c) 2022 PANTHEON.tech s.r.o.
# Copyright(c) 2022 University of New Hampshire
import argparse
import os
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Any, TypeVar
_T = TypeVar("_T")
def _env_arg(env_var: str) -> Any:
class _EnvironmentArgument(argparse.Action):
def __init__(
self,
option_strings: Sequence[str],
dest: str,
nargs: str | int | None = None,
const: str | None = None,
default: str = None,
type: Callable[[str], _T | argparse.FileType | None] = None,
choices: Iterable[_T] | None = None,
required: bool = True,
help: str | None = None,
metavar: str | tuple[str, ...] | None = None,
) -> None:
env_var_value = os.environ.get(env_var)
default = env_var_value or default
super(_EnvironmentArgument, self).__init__(
option_strings,
dest,
nargs=nargs,
const=const,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar,
)
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: Any,
option_string: str = None,
) -> None:
setattr(namespace, self.dest, values)
return _EnvironmentArgument
@dataclass(slots=True, frozen=True)
class _Settings:
config_file_path: str
def _get_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="DPDK test framework.")
parser.add_argument(
"--config-file",
action=_env_arg("DTS_CFG_FILE"),
default="conf.yaml",
required=False,
help="[DTS_CFG_FILE] configuration file that describes the test cases, SUTs "
"and targets.",
)
return parser
def _get_settings() -> _Settings:
parsed_args = _get_parser().parse_args()
return _Settings(
config_file_path=parsed_args.config_file,
)
SETTINGS: _Settings = _get_settings()