Skip to content

Commit 99e6eb3

Browse files
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.
1 parent 396e4d5 commit 99e6eb3

File tree

2 files changed

+191
-44
lines changed

2 files changed

+191
-44
lines changed

scripts/runtests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ validate () {
5353
answers-core)
5454
;;
5555
*)
56-
python3 scripts/validate-autoinstall-user-data.py < $tmpdir/var/log/installer/autoinstall-user-data
56+
python3 scripts/validate-autoinstall-user-data.py --check-link < $tmpdir/var/log/installer/autoinstall-user-data
5757
# After the lunar release and the introduction of mirror testing, it
5858
# came to our attention that new Ubuntu installations have the security
5959
# repository configured with the primary mirror URL (i.e.,

scripts/validate-autoinstall-user-data.py

Lines changed: 190 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -27,63 +27,210 @@
2727
"""
2828

2929
import argparse
30+
import asyncio
3031
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
3239

33-
import jsonschema
3440
import yaml
3541

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+
)
61154
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"]
68156

69157

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)
71195

72-
stream_pos: int = user_data.tell()
196+
print("Failure: The provided autoinstall config did not validate succesfully")
197+
return 1
73198

74-
data: str = user_data.read()
199+
print("Success: The provided autoinstall config validated succesfully")
200+
return 0
75201

76-
link: str = "https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html" # noqa: E501
77202

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
79225

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)
82230

83-
data = yaml.safe_load(user_data)
231+
ret_code = asyncio.run(verify_autoinstall(path, verbosity=args.verbosity))
84232

85-
jsonschema.validate(get_autoinstall_data(data),
86-
json.load(args["json_schema"]))
233+
sys.exit(ret_code)
87234

88235

89236
if __name__ == "__main__":

0 commit comments

Comments
 (0)