Skip to content

Commit 52ccadd

Browse files
Rafid Bin Mostofagregory-schiano
authored andcommitted
chore(main): add reusable workflow to test installing slices (canonical#119)
1 parent ac464ef commit 52ccadd

5 files changed

Lines changed: 716 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
devscripts
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
#!/usr/bin/python3
2+
3+
"""
4+
Verify chisel slice definition files by installing the slices.
5+
6+
Usage
7+
-----
8+
install_slices [-h] --arch ARCH --release RELEASE [--dry-run]
9+
[--ensure-existence] [--ignore-missing] [file ...]
10+
11+
positional arguments:
12+
file Chisel slice definition file(s)
13+
14+
options:
15+
-h, --help show this help message and exit
16+
--arch ARCH Package architecture
17+
--release RELEASE chisel-releases branch name or directory
18+
--dry-run Perform dry run: do not actually install the slices
19+
--ensure-existence Each package must exist in the archive for at least one architecture
20+
--ignore-missing Ignore arch-specific package not found in archive errors
21+
"""
22+
23+
import argparse
24+
from dataclasses import dataclass
25+
import logging
26+
import os
27+
import subprocess
28+
import tempfile
29+
import sys
30+
31+
import requests
32+
import yaml
33+
34+
35+
def configure_logging() -> None:
36+
"""
37+
Configure the logging options for this script.
38+
"""
39+
logging.basicConfig(
40+
format="%(levelname)s: %(message)s",
41+
level=logging.INFO,
42+
)
43+
44+
45+
def parse_args() -> argparse.Namespace:
46+
"""
47+
Parse CLI args passed to this script.
48+
"""
49+
parser = argparse.ArgumentParser(
50+
description="Verify slice definition files by installing the slices",
51+
)
52+
parser.add_argument(
53+
"--arch",
54+
required=True,
55+
help="Package architecture",
56+
)
57+
parser.add_argument(
58+
"--release",
59+
required=True,
60+
help="chisel-releases branch name or directory",
61+
)
62+
parser.add_argument(
63+
"--dry-run",
64+
required=False,
65+
action="store_true",
66+
help="Perform dry run: do not actually install the slices",
67+
)
68+
parser.add_argument(
69+
"--ensure-existence",
70+
required=False,
71+
action="store_true",
72+
help="Each package must exist in the archive for at least one architecture",
73+
)
74+
parser.add_argument(
75+
"--ignore-missing",
76+
required=False,
77+
action="store_true",
78+
help="Ignore arch-specific package not found in archive errors",
79+
)
80+
parser.add_argument(
81+
"files",
82+
metavar="file",
83+
help="Chisel slice definition file(s)",
84+
nargs="*",
85+
)
86+
return parser.parse_args()
87+
88+
89+
@dataclass
90+
class Archive:
91+
"""
92+
Minimal data class replicating ubuntu archive in chisel.yaml.
93+
"""
94+
95+
version: str
96+
components: list[str]
97+
suites: list[str]
98+
99+
100+
def parse_archive(release: str) -> Archive:
101+
"""
102+
Parse the "ubuntu" archive from the chisel.yaml file of a release.
103+
The chisel.yaml file has the following structure:
104+
...
105+
archives:
106+
ubuntu:
107+
version: 22.04
108+
components: [main, universe]
109+
suites: [jammy, jammy-security, jammy-updates]
110+
...
111+
...
112+
"""
113+
logging.debug("Parsing ubuntu archive info...")
114+
# (download and) parse chisel.yaml for ubuntu archive info
115+
try:
116+
if "/" in release:
117+
filepath = os.path.join(release, "chisel.yaml")
118+
with open(filepath, "r", encoding="utf-8") as stream:
119+
data = yaml.safe_load(stream)
120+
else:
121+
base_url = "https://raw.githubusercontent.com/canonical/chisel-releases"
122+
req_url = f"{base_url}/{release}/chisel.yaml"
123+
response = requests.get(req_url, timeout=30)
124+
response.raise_for_status()
125+
data = yaml.safe_load(response.content)
126+
except yaml.YAMLError as e:
127+
logging.error("chisel.yaml: %s", e)
128+
sys.exit(1)
129+
# load the yaml data into Archive
130+
archive_data = data["archives"]["ubuntu"]
131+
archive = Archive(
132+
str(archive_data["version"]), archive_data["components"], archive_data["suites"]
133+
)
134+
return archive
135+
136+
137+
@dataclass
138+
class Package:
139+
"""
140+
Minimal data class to store package info.
141+
"""
142+
143+
package: str
144+
slices: list[str]
145+
146+
147+
def full_slice_name(pkg: str, slice: str) -> str:
148+
"""
149+
Return the full slice name in "pkg_slice" format.
150+
"""
151+
return f"{pkg}_{slice}"
152+
153+
154+
def parse_package(filepath: str) -> Package:
155+
"""
156+
Parse a slice definition file and return the Package.
157+
"""
158+
logging.debug("Parsing %s...", filepath)
159+
with open(filepath, "r", encoding="utf-8") as stream:
160+
try:
161+
data = yaml.safe_load(stream)
162+
except yaml.YAMLError as e:
163+
logging.error("%s: %s", filepath, e)
164+
sys.exit(1)
165+
try:
166+
package = data["package"]
167+
slices = list(data["slices"].keys())
168+
slices = sorted(slices)
169+
except KeyError as e:
170+
logging.error("%s: key %s not found", filepath, e)
171+
sys.exit(1)
172+
pkg = Package(package, slices)
173+
return pkg
174+
175+
176+
def query_package_existence(
177+
packages: list[str],
178+
archive: Archive,
179+
arch: list[str] | None = None,
180+
) -> tuple[list[str], list[str]]:
181+
"""
182+
Check which packages exist in the archive. Return a list of packages
183+
that exist and another list for which do not.
184+
"""
185+
# Prepare cmd.
186+
args = ["rmadison"]
187+
if arch and len(arch) > 0:
188+
args += ["--architecture", ",".join(arch)]
189+
if len(archive.components) > 0:
190+
args += ["--component", ",".join(archive.components)]
191+
if len(archive.suites) > 0:
192+
args += ["--suite", ",".join(archive.suites)]
193+
args.append(" ".join(packages))
194+
# Query the archives using rmadison.
195+
logging.debug("Querying the archives for packages...")
196+
logging.debug("Executing %s", " ".join(args))
197+
res = subprocess.run(args, capture_output=True, text=True, check=False)
198+
if res.returncode != 0:
199+
logging.error("Failed to query the archives %d", res.returncode)
200+
sys.exit(res.returncode)
201+
output = res.stdout.rstrip()
202+
logging.debug("Archive query output:\n%s", output)
203+
# Parse the output for available packages.
204+
found = []
205+
for line in output.split("\n"):
206+
line = line.strip()
207+
if line == "":
208+
continue
209+
pkg = line.split("|")[0].strip()
210+
found.append(pkg)
211+
found = list(set(found))
212+
missing = list(set(packages) - set(found))
213+
return sorted(found), sorted(missing)
214+
215+
216+
def ensure_package_existence(packages: list[str], archive: Archive) -> None:
217+
"""
218+
Ensure that packages exist in the archive for any arch.
219+
"""
220+
logging.info("Ensuring packages existence in ubuntu-%s archive...", archive.version)
221+
_, missing = query_package_existence(packages, archive)
222+
if len(missing) > 0:
223+
logging.error(
224+
"The following packages do not exist for ubuntu-%s:\n%s",
225+
archive.version,
226+
"\n".join(f" - {p}" for p in missing),
227+
)
228+
sys.exit(1)
229+
230+
231+
def ignore_missing_packages(
232+
packages: list[Package],
233+
arch: str,
234+
release: str,
235+
) -> tuple[list[Package], list[Package]]:
236+
"""
237+
Filter the packages that do not exist in the archive for [arch, release].
238+
"""
239+
package_names = [p.package for p in packages]
240+
archive = parse_archive(release)
241+
found, _ = query_package_existence(package_names, archive, arch=[arch])
242+
#
243+
logging.info("Ignoring missing packages in ubuntu-%s/%s...", archive.version, arch)
244+
filtered = []
245+
ignored = []
246+
for p in packages:
247+
if p.package in found:
248+
filtered.append(p)
249+
else:
250+
ignored.append(p)
251+
return filtered, ignored
252+
253+
254+
def install_slice(slice: str, arch: str, release: str) -> None:
255+
"""
256+
Install the slice by running "chisel cut".
257+
"""
258+
logging.info("Installing %s on %s...", slice, arch)
259+
with tempfile.TemporaryDirectory() as tmpfs:
260+
res = subprocess.run(
261+
args=[
262+
"chisel",
263+
"cut",
264+
"--arch",
265+
arch,
266+
"--release",
267+
release,
268+
"--root",
269+
tmpfs,
270+
slice,
271+
],
272+
capture_output=True,
273+
text=True,
274+
check=False,
275+
)
276+
if res.returncode != 0:
277+
logging.error(
278+
"==============================================\n%s",
279+
res.stderr.rstrip(),
280+
)
281+
sys.exit(res.returncode)
282+
283+
284+
def main() -> None:
285+
"""
286+
The main function -- execution should start from here.
287+
"""
288+
configure_logging()
289+
cli_args = parse_args()
290+
# Parse slice definition files.
291+
packages = []
292+
for file in cli_args.files:
293+
pkg = parse_package(file)
294+
packages.append(pkg)
295+
# Ensure package existence for at least one architecture. This means that
296+
# each package must be present in the archive for at least one of the
297+
# architectures.
298+
if cli_args.ensure_existence:
299+
archive = parse_archive(cli_args.release)
300+
ensure_package_existence([p.package for p in packages], archive)
301+
# Ignore packages who do not exist in the archive for this particular
302+
# architecture.
303+
if cli_args.ignore_missing:
304+
packages, ignored = ignore_missing_packages(
305+
packages, cli_args.arch, cli_args.release
306+
)
307+
if len(ignored) > 0:
308+
logging.info("The following packages will be IGNORED:")
309+
for pkg in ignored:
310+
logging.info(" - %s", pkg.package)
311+
#
312+
if len(packages) > 0:
313+
logging.info("Slices of the following packages will be INSTALLED:")
314+
for pkg in packages:
315+
logging.info(" - %s", pkg.package)
316+
else:
317+
logging.info("No slices will be installed.")
318+
return
319+
# Install the slices in each package.
320+
for pkg in packages:
321+
for slice in pkg.slices:
322+
if cli_args.dry_run:
323+
logging.info(
324+
"Installing %s on %s... (--dry-run)",
325+
full_slice_name(pkg.package, slice),
326+
cli_args.arch,
327+
)
328+
else:
329+
install_slice(
330+
full_slice_name(pkg.package, slice),
331+
cli_args.arch,
332+
cli_args.release,
333+
)
334+
335+
336+
if __name__ == "__main__":
337+
main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pyyaml
2+
requests

0 commit comments

Comments
 (0)