subiquity/scripts/validate-autoinstall-user-d...

251 lines
7.3 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright 2022 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
""" Entry-point to validate autoinstall-user-data against schema.
By default, we are expecting the autoinstall user-data to be wrapped in a cloud
config format:
#cloud-config
autoinstall:
<user data comes here>
To validate the user-data directly, you can pass the --no-expect-cloudconfig
switch.
"""
import argparse
import asyncio
import io
import sys
import tempfile
import traceback
from argparse import Namespace
from pathlib import Path
from textwrap import dedent
from typing import Any
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))
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
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="-",
)
parser.add_argument(
"--no-expect-cloudconfig",
dest="expect_cloudconfig",
action="store_false",
help="Assume the data is not wrapped in cloud-config.",
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: Namespace = parser.parse_args()
return args
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:
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
)
if link not in str_data:
raise AssertionError("Documentation link missing from user data")
# 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)
ai_data: str = yaml.dump(ai_dict)
else:
ai_data = str_data
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "autoinstall.yaml"
with open(path, "w") as tf:
tf.write(ai_data)
ret_code = asyncio.run(
verify_autoinstall(path, verbosity=args.verbosity)
)
return ret_code
if __name__ == "__main__":
sys.exit(main())