#!/usr/bin/env python3 # # This file and its contents are supplied under the terms of the # Common Development and Distribution License ("CDDL"), version 1.0. # You may only use this file in accordance with the terms of version # 1.0 of the CDDL. # # A full copy of the text of the CDDL should have accompanied this # source. A copy of the CDDL is also available via the Internet at # http://www.illumos.org/license/CDDL. # # # Copyright 2020 Adam Stevko # # # userland-zone - tool to manage userland template and build zone lifecycle # import argparse import errno import logging import os import pwd import subprocess logger = logging.getLogger("userland-zone") BUILD_ZONE_NAME = "bz" TEMPLATE_ZONE_NAME = "-".join([BUILD_ZONE_NAME, "template"]) ZONES_ROOT = "/zones" ARCHIVES_DIR = "/ws/archives" CODE_DIR = "/ws/code" DEVELOPER_PACKAGES = ["build-essential"] class Zone: def __init__( self, name, path=None, brand="nlipkg", user="oi", user_id=1000, ): self._name = name self._path = path or self._get_zone_path() self._brand = brand self._user = user self._user_id = user_id @property def name(self): return self._name @property def state(self): output = self._zoneadm(["list", "-p"], stdout=subprocess.PIPE) # -:name:installed:/zones/name:22dae542-e5ce-c86a-e5c7-e34d74b696bf:nlipkg:shared return output.split(":")[2] @property def is_running(self): return self.state == "running" def _zonecfg(self, args, **kwargs): zonecfg_args = ["-z", self._name] zonecfg_args.extend(args) return execute("zonecfg", zonecfg_args, **kwargs) def _zoneadm(self, args, **kwargs): zoneadm_args = ["-z", self._name] zoneadm_args.extend(args) return execute("zoneadm", zoneadm_args, **kwargs) def _pkg(self, args, **kwargs): return execute("pkg", args, **kwargs) def _get_zone_path(self): output = self._zoneadm(["list", "-p"], stdout=subprocess.PIPE, check=False) # -:name:installed:/zones/name:22dae542-e5ce-c86a-e5c7-e34d74b696bf:nlipkg:shared return output.split(":")[3] def create(self, extra_args=None): if not self._path: raise ValueError("Zone path is required when creating the zone") zonecfg_args = [ "create -b", "set zonepath={path}".format(path=self._path), "set zonename={name}".format(name=self._name), "set brand={brand}".format(brand=self._brand), ] if extra_args: zonecfg_args.extend(extra_args) zonecfg_args.append("commit") zonecfg_args = [";".join(zonecfg_args)] return self._zonecfg(zonecfg_args) def delete(self): return self._zonecfg(["delete", "-F"]) def install(self): return self._zoneadm(["install", "-e"] + DEVELOPER_PACKAGES) def uninstall(self): return self._zoneadm(["uninstall", "-F"]) def halt(self): return self._zoneadm(["halt"]) def boot(self): return self._zoneadm(["boot"]) def clone(self, source_zone): return self._zoneadm(["clone", source_zone.name]) def update(self): zone_root_path = os.path.join(self._path, "root") return self._pkg(["-R", zone_root_path, "update"]) def configure(self, sysding_lines=None): sysding_config = [ "#!/usr/bin/ksh", "setup_timezone UTC", "setup_locale en_US.UTF-8", 'setup_user_account {user} {uid} {gid} "{gecos}" {home} {shell}'.format( user=self._user, uid=self._user_id, gid="10", gecos="Build user", home="/export/home/{}".format(self._user), shell="/usr/bin/bash", ), "/usr/sbin/usermod -P'Primary Administrator' {user}".format( user=self._user ), ] if sysding_lines: sysding_config.extend(sysding_lines) sysding_config.append("\n") sysding_conf = os.path.join(self._path, "root", "etc", "sysding.conf") with open(sysding_conf, "w") as f: f.write("\n".join(sysding_config)) def execute(cmd, args, **kwargs): ret, result = None, None if "check" not in kwargs: kwargs["check"] = True try: logger.debug('Executing "%s"', " ".join([cmd] + args)) result = subprocess.run([cmd] + args, **kwargs) except subprocess.CalledProcessError as e: logger.error( 'Command "%s" exited with %s', " ".join([cmd] + args), e.returncode ) except OSError as e: if e.errno == errno.ENOENT: raise ValueError("{} cannot be found".format(cmd)) if result and result.stdout: ret = result.stdout.decode() ret = ret.rstrip() return ret def parse_arguments(): def dir_path(path): if os.path.exists(path) and os.path.isdir(path): return path raise argparse.ArgumentTypeError("{} is not a valid path".format(path)) parser = argparse.ArgumentParser() parser.add_argument("--prefix", default=BUILD_ZONE_NAME, help="Zone name prefix") subparsers = parser.add_subparsers(title="subcommands", dest="subcommand") # create-template ct_parser = subparsers.add_parser( "create-template", help="Create template zone for userland builds" ) ct_parser.add_argument( "-p", help="Zone path", default=os.path.join(ZONES_ROOT, TEMPLATE_ZONE_NAME), dest="zone_path", ) ct_parser.add_argument( "-u", help="User ID to use for build user", default=os.getuid(), dest="uid", ) ct_parser.add_argument( "-l", help="User name to use for build user", default=pwd.getpwuid(os.getuid()).pw_name, dest="user", ) # update-template _ = subparsers.add_parser( "update-template", help="Update template zone for userland builds" ) # destroy-template _ = subparsers.add_parser( "destroy-template", help="Destroy template zone for userland builds" ) # spawn-zone sz_parser = subparsers.add_parser( "spawn-zone", help="Spawn build zone for userland builds" ) sz_parser.add_argument( "--id", required=True, help="Zone identifier that identifies build zone" ) sz_parser.add_argument( "--archive-dir", default=ARCHIVES_DIR, type=dir_path, help="Path to userland archives", ) sz_parser.add_argument( "--code-dir", default=CODE_DIR, type=dir_path, help="Path to userland code repository checkoutt", ) # destroy-zone dz_parser = subparsers.add_parser("destroy-zone", help="Destroy build zone") dz_parser.add_argument( "--id", required=True, help="Zone identifier that identifies build zone" ) args = parser.parse_args() if not args.subcommand: parser.print_help() exit(1) return args def create_template(zone_path, zone_name=TEMPLATE_ZONE_NAME, user='oi', user_id=1000): zone = Zone(path=zone_path, name=zone_name, user=user, user_id=user_id) zone.create() zone.install() zone.configure() def destroy_zone(zone_name=TEMPLATE_ZONE_NAME): zone = Zone(path=os.path.join(ZONES_ROOT, zone_name), name=zone_name) if zone.is_running: zone.halt() zone.uninstall() zone.delete() def spawn_zone(id, prefix=BUILD_ZONE_NAME, archive_dir=ARCHIVES_DIR, code_dir=CODE_DIR): name = "{}-{}".format(prefix, id) zone = Zone(name=name, path=os.path.join(ZONES_ROOT, name)) template_zone = Zone(name=TEMPLATE_ZONE_NAME) # To avoid zonecfg failure, we will check for existence of archive_dir and code_dir if not os.path.exists(archive_dir): raise ValueError( "Userland archives dir {} has to exist in the global zone before we can clone the build zone!" ) if not os.path.exists(code_dir): raise ValueError( "Userland archives dir {} has to exist in the global zone before we can clone the build zone!" ) extra_args = [ "add fs", "set dir = {}".format(ARCHIVES_DIR), "set special = {}".format(archive_dir), "set type = lofs", "add options [rw,nodevices]", "end", "add fs", "set dir = {}".format(CODE_DIR), "set special = {}".format(code_dir), "set type = lofs", "add options [rw,nodevices]", "end", ] zone.create(extra_args=extra_args) zone.clone(template_zone) zone.boot() def update_template(zone_name=TEMPLATE_ZONE_NAME): zone = Zone(name=zone_name) zone.update() def main(): args = parse_arguments() if args.subcommand == "create-template": zone_name = '{}-{}'.format(args.prefix, 'template') create_template(zone_path=args.zone_path, zone_name=zone_name, user=args.user, user_id=args.uid) elif args.subcommand == "destroy-template": zone_name = '{}-{}'.format(args.prefix, 'template') destroy_zone(zone_name=zone_name) elif args.subcommand == "update-template": zone_name = '{}-{}'.format(args.prefix, 'template') update_template(zone_name=zone_name) elif args.subcommand == "spawn-zone": spawn_zone(id=args.id, prefix=args.prefix, archive_dir=args.archive_dir, code_dir=args.code_dir) elif args.subcommand == "destroy-zone": zone_name = "{}-{}".format(args.prefix, args.id) destroy_zone(zone_name=zone_name) if __name__ == "__main__": main()