Marcel Telka
2024-04-05 cf51021cfb143a919a6c673ecd9fcc8b9172b7d7
commit | author | age
9c6c5a 1 #!/usr/bin/env python3
2
3 #
4 # This file and its contents are supplied under the terms of the
5 # Common Development and Distribution License ("CDDL"), version 1.0.
6 # You may only use this file in accordance with the terms of version
7 # 1.0 of the CDDL.
8 #
9 # A full copy of the text of the CDDL should have accompanied this
10 # source.  A copy of the CDDL is also available via the Internet at
11 # http://www.illumos.org/license/CDDL.
12 #
13
14 #
15 # Copyright 2020 Adam Stevko
16 #
17
18 #
19 # userland-zone - tool to manage userland template and build zone lifecycle
20 #
21
22 import argparse
23 import errno
24 import logging
25 import os
bf604a 26 import pwd
9c6c5a 27 import subprocess
28
29 logger = logging.getLogger("userland-zone")
30
bf604a 31 BUILD_ZONE_NAME = "bz"
9c6c5a 32 TEMPLATE_ZONE_NAME = "-".join([BUILD_ZONE_NAME, "template"])
33 ZONES_ROOT = "/zones"
34 ARCHIVES_DIR = "/ws/archives"
35 CODE_DIR = "/ws/code"
36 DEVELOPER_PACKAGES = ["build-essential"]
37
38
39 class Zone:
bf604a 40     def __init__(
41         self, name, path=None, brand="nlipkg", user="oi", user_id=1000,
42     ):
9c6c5a 43         self._name = name
44         self._path = path or self._get_zone_path()
45         self._brand = brand
bf604a 46         self._user = user
47         self._user_id = user_id
9c6c5a 48
49     @property
50     def name(self):
51         return self._name
52
53     @property
54     def state(self):
55         output = self._zoneadm(["list", "-p"], stdout=subprocess.PIPE)
56         # -:name:installed:/zones/name:22dae542-e5ce-c86a-e5c7-e34d74b696bf:nlipkg:shared
57         return output.split(":")[2]
58
59     @property
60     def is_running(self):
61         return self.state == "running"
62
63     def _zonecfg(self, args, **kwargs):
64         zonecfg_args = ["-z", self._name]
65         zonecfg_args.extend(args)
66         return execute("zonecfg", zonecfg_args, **kwargs)
67
68     def _zoneadm(self, args, **kwargs):
69         zoneadm_args = ["-z", self._name]
70         zoneadm_args.extend(args)
71         return execute("zoneadm", zoneadm_args, **kwargs)
72
73     def _pkg(self, args, **kwargs):
74         return execute("pkg", args, **kwargs)
75
76     def _get_zone_path(self):
77         output = self._zoneadm(["list", "-p"], stdout=subprocess.PIPE, check=False)
78         # -:name:installed:/zones/name:22dae542-e5ce-c86a-e5c7-e34d74b696bf:nlipkg:shared
79         return output.split(":")[3]
80
81     def create(self, extra_args=None):
82         if not self._path:
83             raise ValueError("Zone path is required when creating the zone")
84
85         zonecfg_args = [
86             "create -b",
87             "set zonepath={path}".format(path=self._path),
88             "set zonename={name}".format(name=self._name),
89             "set brand={brand}".format(brand=self._brand),
90         ]
91
92         if extra_args:
93             zonecfg_args.extend(extra_args)
94         zonecfg_args.append("commit")
95
96         zonecfg_args = [";".join(zonecfg_args)]
97         return self._zonecfg(zonecfg_args)
98
99     def delete(self):
100         return self._zonecfg(["delete", "-F"])
101
102     def install(self):
103         return self._zoneadm(["install", "-e"] + DEVELOPER_PACKAGES)
104
105     def uninstall(self):
106         return self._zoneadm(["uninstall", "-F"])
107
108     def halt(self):
109         return self._zoneadm(["halt"])
110
111     def boot(self):
112         return self._zoneadm(["boot"])
113
114     def clone(self, source_zone):
115         return self._zoneadm(["clone", source_zone.name])
116
117     def update(self):
118         zone_root_path = os.path.join(self._path, "root")
119         return self._pkg(["-R", zone_root_path, "update"])
120
bf604a 121     def configure(self, sysding_lines=None):
122         sysding_config = [
123             "#!/usr/bin/ksh",
124             "setup_timezone UTC",
125             "setup_locale en_US.UTF-8",
126             'setup_user_account {user} {uid} {gid} "{gecos}" {home} {shell}'.format(
127                 user=self._user,
128                 uid=self._user_id,
129                 gid="10",
130                 gecos="Build user",
131                 home="/export/home/{}".format(self._user),
132                 shell="/usr/bin/bash",
133             ),
134             "/usr/sbin/usermod -P'Primary Administrator' {user}".format(
135                 user=self._user
136             ),
137         ]
138
139         if sysding_lines:
140             sysding_config.extend(sysding_lines)
141         sysding_config.append("\n")
142
9c6c5a 143         sysding_conf = os.path.join(self._path, "root", "etc", "sysding.conf")
144         with open(sysding_conf, "w") as f:
bf604a 145             f.write("\n".join(sysding_config))
9c6c5a 146
147
148 def execute(cmd, args, **kwargs):
149     ret, result = None, None
150
151     if "check" not in kwargs:
152         kwargs["check"] = True
153
154     try:
155         logger.debug('Executing "%s"', " ".join([cmd] + args))
156         result = subprocess.run([cmd] + args, **kwargs)
157     except subprocess.CalledProcessError as e:
158         logger.error(
159             'Command "%s" exited with %s', " ".join([cmd] + args), e.returncode
160         )
161     except OSError as e:
162         if e.errno == errno.ENOENT:
163             raise ValueError("{} cannot be found".format(cmd))
164
165     if result and result.stdout:
166         ret = result.stdout.decode()
167         ret = ret.rstrip()
168
169     return ret
170
171
172 def parse_arguments():
173     def dir_path(path):
174         if os.path.exists(path) and os.path.isdir(path):
175             return path
176         raise argparse.ArgumentTypeError("{} is not a valid path".format(path))
177
178     parser = argparse.ArgumentParser()
bf604a 179     parser.add_argument("--prefix", default=BUILD_ZONE_NAME, help="Zone name prefix")
9c6c5a 180     subparsers = parser.add_subparsers(title="subcommands", dest="subcommand")
181
182     # create-template
183     ct_parser = subparsers.add_parser(
184         "create-template", help="Create template zone for userland builds"
185     )
186     ct_parser.add_argument(
187         "-p",
188         help="Zone path",
189         default=os.path.join(ZONES_ROOT, TEMPLATE_ZONE_NAME),
190         dest="zone_path",
bf604a 191     )
192     ct_parser.add_argument(
193         "-u",
194         help="User ID to use for build user",
195         default=os.getuid(),
196         dest="uid",
197     )
198     ct_parser.add_argument(
199         "-l",
200         help="User name to use for build user",
201         default=pwd.getpwuid(os.getuid()).pw_name,
202         dest="user",
9c6c5a 203     )
204
205     # update-template
206     _ = subparsers.add_parser(
207         "update-template", help="Update template zone for userland builds"
208     )
209
210     # destroy-template
211     _ = subparsers.add_parser(
212         "destroy-template", help="Destroy template zone for userland builds"
213     )
214
215     # spawn-zone
216     sz_parser = subparsers.add_parser(
217         "spawn-zone", help="Spawn build zone for userland builds"
218     )
219     sz_parser.add_argument(
220         "--id", required=True, help="Zone identifier that identifies build zone"
221     )
222     sz_parser.add_argument(
223         "--archive-dir",
224         default=ARCHIVES_DIR,
225         type=dir_path,
226         help="Path to userland archives",
227     )
228     sz_parser.add_argument(
229         "--code-dir",
230         default=CODE_DIR,
231         type=dir_path,
232         help="Path to userland code repository checkoutt",
233     )
234
235     # destroy-zone
236     dz_parser = subparsers.add_parser("destroy-zone", help="Destroy build zone")
237     dz_parser.add_argument(
238         "--id", required=True, help="Zone identifier that identifies build zone"
239     )
240
241     args = parser.parse_args()
242
243     if not args.subcommand:
244         parser.print_help()
245         exit(1)
246
247     return args
248
249
bf604a 250 def create_template(zone_path, zone_name=TEMPLATE_ZONE_NAME, user='oi', user_id=1000):
251     zone = Zone(path=zone_path, name=zone_name, user=user, user_id=user_id)
9c6c5a 252     zone.create()
253     zone.install()
254     zone.configure()
255
256
257 def destroy_zone(zone_name=TEMPLATE_ZONE_NAME):
258     zone = Zone(path=os.path.join(ZONES_ROOT, zone_name), name=zone_name)
259
260     if zone.is_running:
261         zone.halt()
262     zone.uninstall()
263     zone.delete()
264
265
bf604a 266 def spawn_zone(id, prefix=BUILD_ZONE_NAME, archive_dir=ARCHIVES_DIR, code_dir=CODE_DIR):
267     name = "{}-{}".format(prefix, id)
9c6c5a 268     zone = Zone(name=name, path=os.path.join(ZONES_ROOT, name))
269
270     template_zone = Zone(name=TEMPLATE_ZONE_NAME)
271
272     # To avoid zonecfg failure, we will check for existence of archive_dir and code_dir
273     if not os.path.exists(archive_dir):
274         raise ValueError(
275             "Userland archives dir {} has to exist in the global zone before we can clone the build zone!"
276         )
277
278     if not os.path.exists(code_dir):
279         raise ValueError(
280             "Userland archives dir {} has to exist in the global zone before we can clone the build zone!"
281         )
282
283     extra_args = [
284         "add fs",
285         "set dir = {}".format(ARCHIVES_DIR),
286         "set special = {}".format(archive_dir),
287         "set type  = lofs",
288         "add options [rw,nodevices]",
289         "end",
290         "add fs",
291         "set dir = {}".format(CODE_DIR),
292         "set special = {}".format(code_dir),
293         "set type  = lofs",
294         "add options [rw,nodevices]",
295         "end",
296     ]
297     zone.create(extra_args=extra_args)
298     zone.clone(template_zone)
299     zone.boot()
300
301
bf604a 302 def update_template(zone_name=TEMPLATE_ZONE_NAME):
303     zone = Zone(name=zone_name)
9c6c5a 304     zone.update()
305
306
307 def main():
308     args = parse_arguments()
309
310     if args.subcommand == "create-template":
bf604a 311         zone_name = '{}-{}'.format(args.prefix, 'template')
312         create_template(zone_path=args.zone_path, zone_name=zone_name, user=args.user, user_id=args.uid)
9c6c5a 313     elif args.subcommand == "destroy-template":
bf604a 314         zone_name = '{}-{}'.format(args.prefix, 'template')
315         destroy_zone(zone_name=zone_name)
9c6c5a 316     elif args.subcommand == "update-template":
bf604a 317         zone_name = '{}-{}'.format(args.prefix, 'template')
318         update_template(zone_name=zone_name)
9c6c5a 319     elif args.subcommand == "spawn-zone":
bf604a 320         spawn_zone(id=args.id, prefix=args.prefix, archive_dir=args.archive_dir, code_dir=args.code_dir)
9c6c5a 321     elif args.subcommand == "destroy-zone":
bf604a 322         zone_name = "{}-{}".format(args.prefix, args.id)
9c6c5a 323         destroy_zone(zone_name=zone_name)
324
325
326 if __name__ == "__main__":
327     main()