|
27 | 27 | """
|
28 | 28 |
|
29 | 29 | import argparse
|
| 30 | +import asyncio |
30 | 31 | import io
|
31 |
| -import json |
| 32 | +import sys |
| 33 | +import tempfile |
| 34 | +import traceback |
| 35 | +from argparse import Namespace |
| 36 | +from pathlib import Path |
| 37 | +from textwrap import dedent |
| 38 | +from typing import Any |
32 | 39 |
|
33 |
| -import jsonschema |
34 | 40 | import yaml
|
35 | 41 |
|
36 |
| - |
37 |
| -def main() -> None: |
38 |
| - """ Entry point. """ |
39 |
| - parser = argparse.ArgumentParser() |
40 |
| - |
41 |
| - parser.add_argument("--json-schema", |
42 |
| - help="Path to the JSON schema", |
43 |
| - type=argparse.FileType("r"), |
44 |
| - default="autoinstall-schema.json") |
45 |
| - parser.add_argument("input", nargs="?", |
46 |
| - help="Path to the user data instead of stdin", |
47 |
| - type=argparse.FileType("r"), |
48 |
| - default="-") |
49 |
| - parser.add_argument("--no-expect-cloudconfig", dest="expect-cloudconfig", |
50 |
| - action="store_false", |
51 |
| - help="Assume the data is not wrapped in cloud-config.", |
52 |
| - default=True) |
53 |
| - |
54 |
| - args = vars(parser.parse_args()) |
55 |
| - |
56 |
| - user_data: io.TextIOWrapper = args["input"] |
57 |
| - |
58 |
| - if args["expect-cloudconfig"]: |
59 |
| - assert user_data.readline() == "#cloud-config\n" |
60 |
| - def get_autoinstall_data(data): return data["autoinstall"] |
| 42 | +# Python path trickery so we can import subiquity code and still call this |
| 43 | +# script without using the makefile |
| 44 | +scripts_dir = sys.path[0] |
| 45 | +subiquity_root = Path(scripts_dir) / ".." |
| 46 | +curtin_root = subiquity_root / "curtin" |
| 47 | +probert_root = subiquity_root / "probert" |
| 48 | +# At the very least, local curtin needs to be in the front of the python path |
| 49 | +sys.path.insert(0, str(subiquity_root)) |
| 50 | +sys.path.insert(1, str(curtin_root)) |
| 51 | +sys.path.insert(2, str(probert_root)) |
| 52 | + |
| 53 | +from subiquity.cmd.server import make_server_args_parser # noqa: E402 |
| 54 | +from subiquity.server.dryrun import DRConfig # noqa: E402 |
| 55 | +from subiquity.server.server import SubiquityServer # noqa: E402 |
| 56 | + |
| 57 | + |
| 58 | +def parse_args() -> Namespace: |
| 59 | + """Parse arguments with argparse""" |
| 60 | + |
| 61 | + description: str = dedent( |
| 62 | + """\ |
| 63 | + Validate autoinstall user data against the autoinstall schema. By default |
| 64 | + expects the user data is wrapped in a cloud-config. Example: |
| 65 | +
|
| 66 | + #cloud-config |
| 67 | + autoinstall: |
| 68 | + <user data here> |
| 69 | +
|
| 70 | + To validate the user data directly, you can pass --no-expect-cloudconfig |
| 71 | + """ |
| 72 | + ) |
| 73 | + |
| 74 | + parser = argparse.ArgumentParser( |
| 75 | + prog="validate-autoinstall-user-data", |
| 76 | + description=description, |
| 77 | + formatter_class=argparse.RawDescriptionHelpFormatter, |
| 78 | + ) |
| 79 | + |
| 80 | + parser.add_argument( |
| 81 | + "input", |
| 82 | + help="Path to the autoinstall configuration instead of stdin", |
| 83 | + nargs="?", |
| 84 | + type=argparse.FileType("r"), |
| 85 | + default="-", |
| 86 | + ) |
| 87 | + parser.add_argument( |
| 88 | + "--no-expect-cloudconfig", |
| 89 | + dest="expect_cloudconfig", |
| 90 | + action="store_false", |
| 91 | + help="Assume the data is not wrapped in cloud-config.", |
| 92 | + default=True, |
| 93 | + ) |
| 94 | + parser.add_argument( |
| 95 | + "-v", |
| 96 | + "--verbosity", |
| 97 | + action="count", |
| 98 | + help=( |
| 99 | + "Increase output verbosity. Use -v for more info, -vv for " |
| 100 | + "detailed output, and -vvv for fully detailed output." |
| 101 | + ), |
| 102 | + default=0, |
| 103 | + ) |
| 104 | + # An option we use in CI to make sure Subiquity will insert a link to |
| 105 | + # the documentation in the auto-generated autoinstall file post-install |
| 106 | + parser.add_argument( |
| 107 | + "--check-link", |
| 108 | + dest="check_link", |
| 109 | + action="store_true", |
| 110 | + help=argparse.SUPPRESS, |
| 111 | + default=False, |
| 112 | + ) |
| 113 | + |
| 114 | + args: Namespace = parser.parse_args() |
| 115 | + |
| 116 | + return args |
| 117 | + |
| 118 | + |
| 119 | +def make_app(): |
| 120 | + parser = make_server_args_parser() |
| 121 | + opts, unknown = parser.parse_known_args(["--dry-run"]) |
| 122 | + app = SubiquityServer(opts, "") |
| 123 | + # This is needed because the ubuntu-pro server controller accesses dr_cfg |
| 124 | + # in the initializer. |
| 125 | + app.dr_cfg = DRConfig() |
| 126 | + app.base_model = app.make_model() |
| 127 | + app.controllers.load_all() |
| 128 | + return app |
| 129 | + |
| 130 | + |
| 131 | +def parse_cloud_config(data: str) -> dict[str, Any]: |
| 132 | + """Parse cloud-config and extract autoinstall""" |
| 133 | + |
| 134 | + first_line: str = data.splitlines()[0] |
| 135 | + if not first_line == "#cloud-config": |
| 136 | + raise AssertionError( |
| 137 | + ( |
| 138 | + "Expected data to be wrapped in cloud-config " |
| 139 | + "but first line is not '#cloud-config'. Try " |
| 140 | + "passing --no-expect-cloudconfig." |
| 141 | + ) |
| 142 | + ) |
| 143 | + |
| 144 | + cc_data: dict[str, Any] = yaml.safe_load(data) |
| 145 | + |
| 146 | + if "autoinstall" not in cc_data: |
| 147 | + raise AssertionError( |
| 148 | + ( |
| 149 | + "Expected data to be wrapped in cloud-config " |
| 150 | + "but could not find top level 'autoinstall' " |
| 151 | + "key." |
| 152 | + ) |
| 153 | + ) |
61 | 154 | else:
|
62 |
| - def get_autoinstall_data(data): |
63 |
| - try: |
64 |
| - cfg = data["autoinstall"] |
65 |
| - except KeyError: |
66 |
| - cfg = data |
67 |
| - return cfg |
| 155 | + return cc_data["autoinstall"] |
68 | 156 |
|
69 | 157 |
|
70 |
| - # Verify autoinstall doc link is in the file |
| 158 | +async def verify_autoinstall(cfg_path: str, verbosity: int = 0) -> int: |
| 159 | + """Verify autoinstall configuration. |
| 160 | +
|
| 161 | + Returns 0 if succesfully validated. |
| 162 | + Returns 1 if fails to validate. |
| 163 | + """ |
| 164 | + |
| 165 | + # Make a dry-run server |
| 166 | + app = make_app() |
| 167 | + |
| 168 | + # Supress start and finish events unless verbosity >=2 |
| 169 | + if verbosity < 2: |
| 170 | + for el in app.event_listeners: |
| 171 | + el.report_start_event = lambda x, y: None |
| 172 | + el.report_finish_event = lambda x, y, z: None |
| 173 | + # Suppress info events unless verbosity >=1 |
| 174 | + if verbosity < 1: |
| 175 | + for el in app.event_listeners: |
| 176 | + el.report_info_event = lambda x, y: None |
| 177 | + |
| 178 | + # Tell the server where to load the autoinstall |
| 179 | + app.autoinstall = cfg_path |
| 180 | + # Make sure events are printed (we could fail during read, which |
| 181 | + # would happen before we setup the reporting controller) |
| 182 | + app.controllers.Reporting.config = {"builtin": {"type": "print"}} |
| 183 | + app.controllers.Reporting.start() |
| 184 | + # Do both validation phases |
| 185 | + try: |
| 186 | + app.load_autoinstall_config(only_early=True, context=None) |
| 187 | + app.load_autoinstall_config(only_early=False, context=None) |
| 188 | + except Exception as exc: |
| 189 | + |
| 190 | + print(exc) # Has the useful error message |
| 191 | + |
| 192 | + # Print the full traceback if verbosity >=2 |
| 193 | + if verbosity > 2: |
| 194 | + traceback.print_exception(exc) |
71 | 195 |
|
72 |
| - stream_pos: int = user_data.tell() |
| 196 | + print("Failure: The provided autoinstall config did not validate succesfully") |
| 197 | + return 1 |
73 | 198 |
|
74 |
| - data: str = user_data.read() |
| 199 | + print("Success: The provided autoinstall config validated succesfully") |
| 200 | + return 0 |
75 | 201 |
|
76 |
| - link: str = "https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html" # noqa: E501 |
77 | 202 |
|
78 |
| - assert link in data |
| 203 | +def main() -> None: |
| 204 | + """Entry point.""" |
| 205 | + |
| 206 | + args: Namespace = parse_args() |
| 207 | + |
| 208 | + user_data: io.TextIOWrapper = args.input |
| 209 | + str_data: str = user_data.read() |
| 210 | + |
| 211 | + # Verify autoinstall doc link is in the file |
| 212 | + if args.check_link: |
| 213 | + link: str = ( |
| 214 | + "https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html" # noqa: E501 |
| 215 | + ) |
| 216 | + |
| 217 | + if link not in str_data: |
| 218 | + raise AssertionError("Documentation link missing from user data") |
| 219 | + |
| 220 | + # Parse out the autoinstall if expected within cloud-config |
| 221 | + if args.expect_cloudconfig: |
| 222 | + ai_data: str = yaml.dump(parse_cloud_config(str_data)) |
| 223 | + else: |
| 224 | + ai_data = str_data |
79 | 225 |
|
80 |
| - # Verify autoinstall schema |
81 |
| - user_data.seek(stream_pos) |
| 226 | + with tempfile.TemporaryDirectory() as td: |
| 227 | + path = Path(td) / "autoinstall.yaml" |
| 228 | + with open(path, "w") as tf: |
| 229 | + tf.write(ai_data) |
82 | 230 |
|
83 |
| - data = yaml.safe_load(user_data) |
| 231 | + ret_code = asyncio.run(verify_autoinstall(path, verbosity=args.verbosity)) |
84 | 232 |
|
85 |
| - jsonschema.validate(get_autoinstall_data(data), |
86 |
| - json.load(args["json_schema"])) |
| 233 | + sys.exit(ret_code) |
87 | 234 |
|
88 | 235 |
|
89 | 236 | if __name__ == "__main__":
|
|
0 commit comments