scripts: validate-autoinstall-user-data refresh
./scripts/validate-autoinstall-user-data is used by the integration tests to verify the written user data validates against the combined JSON schema, but we have introduced run-time checks for more things than can be caught by simple JSON validation (e.g. warns/errors on unknown keys or strict top-level key checking for supporting a top-level "autoinstall" keyword in the non-cloud-config delivery scenario). This changes the validation script to rely on the logic from the server directly to perform pre-validation of the the supplied autoinstall configuration. Additionally, this adds an argparser to make it more user-friendly. Now we can advertise this script as something for users to pre-validate their autoinstall configurations.
This commit is contained in:
parent
396e4d5a87
commit
096efce00c
|
@ -53,7 +53,7 @@ validate () {
|
|||
answers-core)
|
||||
;;
|
||||
*)
|
||||
python3 scripts/validate-autoinstall-user-data.py < $tmpdir/var/log/installer/autoinstall-user-data
|
||||
python3 scripts/validate-autoinstall-user-data.py --check-link < $tmpdir/var/log/installer/autoinstall-user-data
|
||||
# After the lunar release and the introduction of mirror testing, it
|
||||
# came to our attention that new Ubuntu installations have the security
|
||||
# repository configured with the primary mirror URL (i.e.,
|
||||
|
|
|
@ -27,64 +27,224 @@ switch.
|
|||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
import yaml
|
||||
|
||||
# Python path trickery so we can import subiquity code and still call this
|
||||
# script without using the makefile
|
||||
scripts_dir = sys.path[0]
|
||||
subiquity_root = Path(scripts_dir) / ".."
|
||||
curtin_root = subiquity_root / "curtin"
|
||||
probert_root = subiquity_root / "probert"
|
||||
# At the very least, local curtin needs to be in the front of the python path
|
||||
sys.path.insert(0, str(subiquity_root))
|
||||
sys.path.insert(1, str(curtin_root))
|
||||
sys.path.insert(2, str(probert_root))
|
||||
|
||||
def main() -> None:
|
||||
""" Entry point. """
|
||||
parser = argparse.ArgumentParser()
|
||||
from subiquity.cmd.server import make_server_args_parser # noqa: E402
|
||||
from subiquity.server.dryrun import DRConfig # noqa: E402
|
||||
from subiquity.server.server import SubiquityServer # noqa: E402
|
||||
|
||||
parser.add_argument("--json-schema",
|
||||
help="Path to the JSON schema",
|
||||
|
||||
SUCCESS_MSG = "Success: The provided autoinstall config validated succesfully"
|
||||
FAILURE_MSG = "Failure: The provided autoinstall config did not validate succesfully"
|
||||
|
||||
|
||||
def parse_args() -> Namespace:
|
||||
"""Parse arguments with argparse"""
|
||||
|
||||
description: str = dedent(
|
||||
"""\
|
||||
Validate autoinstall user data against the autoinstall schema. By default
|
||||
expects the user data is wrapped in a cloud-config. Example:
|
||||
|
||||
#cloud-config
|
||||
autoinstall:
|
||||
<user data here>
|
||||
|
||||
To validate the user data directly, you can pass --no-expect-cloudconfig
|
||||
"""
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="validate-autoinstall-user-data",
|
||||
description=description,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"input",
|
||||
help="Path to the autoinstall configuration instead of stdin",
|
||||
nargs="?",
|
||||
type=argparse.FileType("r"),
|
||||
default="autoinstall-schema.json")
|
||||
parser.add_argument("input", nargs="?",
|
||||
help="Path to the user data instead of stdin",
|
||||
type=argparse.FileType("r"),
|
||||
default="-")
|
||||
parser.add_argument("--no-expect-cloudconfig", dest="expect-cloudconfig",
|
||||
default="-",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-expect-cloudconfig",
|
||||
dest="expect_cloudconfig",
|
||||
action="store_false",
|
||||
help="Assume the data is not wrapped in cloud-config.",
|
||||
default=True)
|
||||
default=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbosity",
|
||||
action="count",
|
||||
help=(
|
||||
"Increase output verbosity. Use -v for more info, -vv for "
|
||||
"detailed output, and -vvv for fully detailed output."
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
# An option we use in CI to make sure Subiquity will insert a link to
|
||||
# the documentation in the auto-generated autoinstall file post-install
|
||||
parser.add_argument(
|
||||
"--check-link",
|
||||
dest="check_link",
|
||||
action="store_true",
|
||||
help=argparse.SUPPRESS,
|
||||
default=False,
|
||||
)
|
||||
|
||||
args = vars(parser.parse_args())
|
||||
args: Namespace = parser.parse_args()
|
||||
|
||||
user_data: io.TextIOWrapper = args["input"]
|
||||
return args
|
||||
|
||||
if args["expect-cloudconfig"]:
|
||||
assert user_data.readline() == "#cloud-config\n"
|
||||
def get_autoinstall_data(data): return data["autoinstall"]
|
||||
|
||||
def make_app():
|
||||
parser = make_server_args_parser()
|
||||
opts, unknown = parser.parse_known_args(["--dry-run"])
|
||||
app = SubiquityServer(opts, "")
|
||||
# This is needed because the ubuntu-pro server controller accesses dr_cfg
|
||||
# in the initializer.
|
||||
app.dr_cfg = DRConfig()
|
||||
app.base_model = app.make_model()
|
||||
app.controllers.load_all()
|
||||
return app
|
||||
|
||||
|
||||
def parse_cloud_config(data: str) -> dict[str, Any]:
|
||||
"""Parse cloud-config and extract autoinstall"""
|
||||
|
||||
first_line: str = data.splitlines()[0]
|
||||
if not first_line == "#cloud-config":
|
||||
raise AssertionError(
|
||||
(
|
||||
"Expected data to be wrapped in cloud-config "
|
||||
"but first line is not '#cloud-config'. Try "
|
||||
"passing --no-expect-cloudconfig."
|
||||
)
|
||||
)
|
||||
|
||||
cc_data: dict[str, Any] = yaml.safe_load(data)
|
||||
|
||||
if "autoinstall" not in cc_data:
|
||||
raise AssertionError(
|
||||
(
|
||||
"Expected data to be wrapped in cloud-config "
|
||||
"but could not find top level 'autoinstall' "
|
||||
"key."
|
||||
)
|
||||
)
|
||||
else:
|
||||
def get_autoinstall_data(data):
|
||||
try:
|
||||
cfg = data["autoinstall"]
|
||||
except KeyError:
|
||||
cfg = data
|
||||
return cfg
|
||||
return cc_data["autoinstall"]
|
||||
|
||||
|
||||
async def verify_autoinstall(cfg_path: str, verbosity: int = 0) -> int:
|
||||
"""Verify autoinstall configuration.
|
||||
|
||||
Returns 0 if succesfully validated.
|
||||
Returns 1 if fails to validate.
|
||||
"""
|
||||
|
||||
# Make a dry-run server
|
||||
app = make_app()
|
||||
|
||||
# Supress start and finish events unless verbosity >=2
|
||||
if verbosity < 2:
|
||||
for el in app.event_listeners:
|
||||
el.report_start_event = lambda x, y: None
|
||||
el.report_finish_event = lambda x, y, z: None
|
||||
# Suppress info events unless verbosity >=1
|
||||
if verbosity < 1:
|
||||
for el in app.event_listeners:
|
||||
el.report_info_event = lambda x, y: None
|
||||
|
||||
# Tell the server where to load the autoinstall
|
||||
app.autoinstall = cfg_path
|
||||
# Make sure events are printed (we could fail during read, which
|
||||
# would happen before we setup the reporting controller)
|
||||
app.controllers.Reporting.config = {"builtin": {"type": "print"}}
|
||||
app.controllers.Reporting.start()
|
||||
# Do both validation phases
|
||||
try:
|
||||
app.load_autoinstall_config(only_early=True, context=None)
|
||||
app.load_autoinstall_config(only_early=False, context=None)
|
||||
except Exception as exc:
|
||||
|
||||
print(exc) # Has the useful error message
|
||||
|
||||
# Print the full traceback if verbosity >=2
|
||||
if verbosity > 2:
|
||||
traceback.print_exception(exc)
|
||||
|
||||
print(FAILURE_MSG)
|
||||
return 1
|
||||
|
||||
print(SUCCESS_MSG)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point."""
|
||||
|
||||
args: Namespace = parse_args()
|
||||
|
||||
user_data: io.TextIOWrapper = args.input
|
||||
str_data: str = user_data.read()
|
||||
|
||||
# Verify autoinstall doc link is in the file
|
||||
if args.check_link:
|
||||
link: str = (
|
||||
"https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html" # noqa: E501
|
||||
)
|
||||
|
||||
stream_pos: int = user_data.tell()
|
||||
if link not in str_data:
|
||||
raise AssertionError("Documentation link missing from user data")
|
||||
|
||||
data: str = user_data.read()
|
||||
# Parse out the autoinstall if expected within cloud-config
|
||||
if args.expect_cloudconfig:
|
||||
try:
|
||||
ai_dict: dict[str, Any] = parse_cloud_config(str_data)
|
||||
except Exception as exc:
|
||||
print(f"{type(exc).__name__}: {exc}")
|
||||
print(FAILURE_MSG)
|
||||
sys.exit(1)
|
||||
|
||||
link: str = "https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html" # noqa: E501
|
||||
ai_data: str = yaml.dump(ai_dict)
|
||||
else:
|
||||
ai_data = str_data
|
||||
|
||||
assert link in data
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / "autoinstall.yaml"
|
||||
with open(path, "w") as tf:
|
||||
tf.write(ai_data)
|
||||
|
||||
# Verify autoinstall schema
|
||||
user_data.seek(stream_pos)
|
||||
ret_code = asyncio.run(
|
||||
verify_autoinstall(path, verbosity=args.verbosity)
|
||||
)
|
||||
|
||||
data = yaml.safe_load(user_data)
|
||||
|
||||
jsonschema.validate(get_autoinstall_data(data),
|
||||
json.load(args["json_schema"]))
|
||||
return ret_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
|
Loading…
Reference in New Issue