# -*- coding: utf-8 -*- import os import shutil import subprocess import sys from enum import Enum from lxcmgr import lxcmgr from lxcmgr.paths import LXC_STORAGE_DIR from lxcmgr.pkgmgr import PkgMgr class ImageExistsError(Exception): pass class ImageNotFoundError(Exception): pass class BuildType(Enum): NORMAL = 1 FORCE = 2 SCRATCH = 3 METADATA = 4 class ImageBuilder: def __init__(self, image): self.image = image self.script = [] self.script_eof = None def build(self): # Read and process lines from lxcfile with open(self.image.lxcfile, 'r') as f: for line in f: line = line.strip() if self.script_eof: if line == self.script_eof: self.script_eof = None self.run_script(self.script) else: self.script.append(line) elif line: self.process_line(*line.split(None, 1)) def process_line(self, directive, args): # Process directives from lxcfile if 'RUN' == directive: self.script = [] self.script_eof = args elif 'IMAGE' == directive: self.set_name(args) elif 'FROM' == directive: self.set_layers(args) elif 'COPY' == directive: srcdst = args.split() self.copy_files(srcdst[0], srcdst[1] if len(srcdst) == 2 else '') elif 'ENV' == directive: self.add_env(*args.split(None, 1)) elif 'USER' == directive: self.set_user(*args.split()) elif 'CMD' == directive: self.set_cmd(args) elif 'WORKDIR' == directive: self.set_cwd(args) elif 'HALT' == directive: self.set_halt(args) elif 'READY' == directive: self.set_ready(args) def get_layer_path(self, layer): return os.path.join(LXC_STORAGE_DIR, layer) def run_script(self, script): # Creates a temporary container, runs a script in its namespace, and stores the modifications as part of the image if self.image.build_type == BuildType.METADATA: # Don't run anything if we're building just metadata return lxcmgr.create_container(self.image.name, self.image.conf) sh = os.path.join(LXC_STORAGE_DIR, self.image.name, 'run.sh') with open(sh, 'w') as f: f.write('#!/bin/sh\nset -ev\n\n{}\n'.format('\n'.join(script))) os.chmod(sh, 0o700) os.chown(sh, 100000, 100000) subprocess.run(['lxc-execute', self.image.name, '--', '/bin/sh', '-lc', '/run.sh'], check=True) os.unlink(sh) if not self.image.build_type == BuildType.SCRATCH: # Don't delete the temporary container if we're doing scratch build lxcmgr.destroy_container(self.image.name) def set_name(self, name): # Set name and first (topmost) layer of the image self.image.name = name self.image.conf['layers'] = [name] if self.image.build_type == BuildType.METADATA: # Don't check or create any directories if we're building just metadata return image_path = self.get_layer_path(name) if os.path.exists(image_path): if self.image.build_type in (BuildType.FORCE, BuildType.SCRATCH): self.clean() else: raise ImageExistsError(image_path) os.makedirs(image_path, 0o755, True) os.chown(image_path, 100000, 100000) def set_layers(self, image): # Extend list of layers with the list of layers from parent image # Raies an exception when IMAGE has no name pkgmgr = PkgMgr() self.image.conf['layers'].extend(pkgmgr.installed_packages['images'][image]['layers']) def copy_files(self, src, dst): # Copy files from the host or download them from a http(s) URL if self.image.build_type == BuildType.METADATA: # Don't copy anything if we're building just metadata return dst = os.path.join(LXC_STORAGE_DIR, self.image.name, dst) if src.startswith('http://') or src.startswith('https://'): unpack_http_archive(src, dst) else: copy_tree(os.path.join(self.image.build_dir, src), dst) # Shift UID/GID of the files to the unprivileged range shift_uid(dst) def add_env(self, key, value): # Sets lxc.environment records for the image if 'env' not in self.image.conf: self.image.conf['env'] = [] self.image.conf['env'].append([key, value]) def set_user(self, uid, gid): # Sets lxc.init.uid/gid for the image self.image.conf['uid'] = uid self.image.conf['gid'] = gid def set_cmd(self, cmd): # Sets lxc.init.cmd for the image self.image.conf['cmd'] = cmd def set_cwd(self, cwd): # Sets lxc.init.cwd for the image self.image.conf['cwd'] = cwd def set_halt(self, halt): # Sets lxc.signal.halt for the image self.image.conf['halt'] = halt def set_ready(self, cmd): # Sets a command performed in OpenRC start_post to check readiness of the container self.image.conf['ready'] = cmd def clean(self): lxcmgr.destroy_container(self.image.name) shutil.rmtree(self.get_layer_path(self.image.name)) def unpack_http_archive(src, dst): # Decompress an archive downloaded via http(s) xf = 'xzf' if src.endswith('.bz2'): xf = 'xjf' elif src.endswith('.xz'): xf = 'xJf' with subprocess.Popen(['wget', src, '-O', '-'], stdout=subprocess.PIPE) as wget: with subprocess.Popen(['tar', xf, '-', '-C', dst], stdin=wget.stdout) as tar: wget.stdout.close() tar.wait() def copy_tree(src, dst): # Copies files from the host if not os.path.isdir(src): shutil.copy2(src, dst) else: os.makedirs(dst, exist_ok=True) for name in os.listdir(src): copy_tree(os.path.join(src, name), os.path.join(dst, name)) shutil.copystat(src, dst) def shift_uid(dir): # Shifts UID/GID of a file or a directory and its contents to the unprivileged range shift_uid_entry(dir, os.stat(dir, follow_symlinks=True)) shift_uid_recursively(dir) def shift_uid_recursively(dir): # Shifts UID/GID of a directory and its contents to the unprivileged range for entry in os.scandir(dir): shift_uid_entry(entry.path, entry.stat(follow_symlinks=False)) if entry.is_dir(): shift_uid_recursively(entry.path) def shift_uid_entry(path, stat): # Shifts UID/GID of a file or a directory to the unprivileged range uid = stat.st_uid gid = stat.st_gid do_chown = False if uid < 100000: uid = uid + 100000 do_chown = True if gid < 100000: gid = gid + 100000 do_chown = True if do_chown: os.lchown(path, uid, gid)