summaryrefslogtreecommitdiff
path: root/lxc_rootfs_debootstrap
blob: fe16912c29565a527c77ecf896448087662ef274 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
#!/usr/bin/python3

description = """
Bootstrap a minimal Debian rootfs to be used as base for LXC containers
with the 'local' template.

Requires cdebootstrap.

Produces a rootfs directory with the specified name and a _meta directory with
LXC metadata.
"""

import argparse
from fnmatch import fnmatch
import os
import os.path
import subprocess
import sys

import os

def open_dirfd(path, mode = 'r', perms = 0o644, dir_fd = None, **kwargs):
    """
    Same as the built-in open(), but with support for file permissions
    and operation with respect to a directory FD.
    """
    flags = 0

    if '+' in mode:
        flags |= os.O_RDWR
    else:
        if 'r' in mode:
            flags |= os.O_RDONLY
        elif 'w' in mode or 'a' in mode:
            flags |= os.O_WRONLY

    if 'w' in mode:
        flags |= os.O_CREAT | os.O_TRUNC
    elif 'a' in mode:
        flags |= os.O_CREAT

    if 'x' in mode:
        flags |= os.O_EXCL

    fd = os.open(path, flags, mode = perms, dir_fd = dir_fd)
    try:
        return os.fdopen(fd, mode, **kwargs)
    except:
        os.close(fd)
        raise

# make sure these packages are present
include_pkgs = (
    # init
    'init',
    'sysvinit-core',

    # packaging
    'apt-utils',
    'aptitude',
    'whiptail',

    # networking
    'netbase',
    'iproute2',
    'dhcpcd',
    'ifupdown',
    'openssh-client',
    'openssh-server',

    # system infrastructure
    'cron',
    'procps',
    'ncurses-term',
    'locales',
    'tzdata',

    # misc utils
    'vim',
    'less',
    'htop',
    'bash-completion',
)

# packages we do NOT want, that would be installed by default
exclude_pkgs = (
    # systemd crap
    'systemd-sysv',
    'systemd',

    # networking: we use dhcpcd and do not need filtering
    'isc-dhcp-client',
    'isc-dhcp-common',
    'iptables',

    # stuff not useful inside a container
    'udev',
    'dmsetup',
    'dmidecode',
    'kmod',

    # misc utils
    'nano',
    'tasksel',
)

# written as /etc/apt/sources.list
apt_sources_template = """
deb     http://deb.debian.org/debian            {release}               main contrib non-free
deb-src http://deb.debian.org/debian            {release}               main contrib non-free

deb     http://deb.debian.org/debian            {release}-backports     main contrib non-free
deb-src http://deb.debian.org/debian            {release}-backports     main contrib non-free

deb     http://deb.debian.org/debian-security   {release}-security      main contrib non-free
deb-src http://deb.debian.org/debian-security   {release}-security      main contrib non-free
"""

# written as /etc/hosts
hosts_template = """
::1     LXC_NAME LXC_NAME.{domain}
::1     localhost ip6-localhost ip6-loopback

fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
"""

# disable initscripts that do not make sense for a container
initscripts_disable = [
    'dhcpcd',
]
    #'mountnfs-bootclean.sh', # XXX
    #'mountnfs.sh',
    #'mountall-bootclean.sh',
    #'mountall.sh',
    #'checkroot-bootclean.sh',
    #'checkfs.sh',
    #'checkroot.sh',
    #'hwclock.sh',
    #'mountdevsubfs.sh',

lxc_templates = """
etc/hosts
etc/hostname
"""

class Bootstrapper:

    _args      = None
    _verbose   = None

    _meta_path = None
    _root_path = None
    _root_fd   = None
    _meta_fd   = None
    _etc_fd    = None

    def __init__(self, args):
        self._args = args

        self._verbose = args.verbose

        # resolve symlinks and normalize the path
        out_path = os.path.normpath(os.path.realpath(args.rootfs_dir))

        # strip possible trailing slash
        if len(out_path) > 1 and out_path[-1] == '/':
            out_path = out_path[:-1]

        # sanitize output directory
        if len(out_path) == 0 or out_path == '/':
            raise ValueError('Invalid output directory: %s' % out_path)

        self._root_path = out_path
        self._meta_path = out_path + '_meta'

        # raise errors if the output directory exists
        os.makedirs(self._root_path)
        os.makedirs(self._meta_path)

        self._root_fd = os.open(self._root_path, os.O_DIRECTORY)
        self._meta_fd = os.open(self._meta_path, os.O_DIRECTORY)

    def close(self):
        for fd_attr in ('root', 'meta', 'etc'):
            name =  '_%s_fd' % fd_attr
            val = getattr(self, name)
            if val is not None:
                os.close(val)
                setattr(self, name, None)

    def _debootstrap(self, release, dst_dir):
        cmdline = ['cdebootstrap', '-f', 'minimal',
                   '--include=%s' % ','.join(include_pkgs), '--exclude=%s' % ','.join(exclude_pkgs)]
        if self._verbose > 1:
            cmdline += ['--verbose']
        elif self._verbose == 0:
            cmdline += ['--quiet']
        cmdline += [release, dst_dir]

        if self._verbose > 1:
            sys.stderr.write('Executing: ' + ' '.join(cmdline) + '\n')
        subprocess.run(cmdline, check = True)

    def _clean_dev(self):
        dev_dir = os.open('dev', os.O_DIRECTORY, dir_fd = self._root_fd)
        try:
            for node in os.listdir(dev_dir):
                if self._verbose > 1:
                    sys.stderr.write('Removing /dev node: %s\n' % node)

                try:
                    os.remove(node, dir_fd = dev_dir)
                except IsADirectoryError:
                    os.rmdir(node, dir_fd = dev_dir)
        finally:
            os.close(dev_dir)

    def _config_locales(self, locales):
        locales = locales.replace(',', '\n')

        with open_dirfd('locale.gen', 'w', dir_fd = self._etc_fd) as f:
            f.write(locales)

        self._exec_chroot(['dpkg-reconfigure', '-fnoninteractive', 'locales'])

    def _config_tz(self, tz):
        tz_path = '/usr/share/zoneinfo/' + tz
        if not os.path.exists(self._root_path + tz_path):
            raise ValueError('Timezone not known: %s' % tz)

        os.remove('localtime', dir_fd = self._etc_fd)
        os.symlink(tz_path, 'etc/localtime', dir_fd = self._root_fd)

        self._exec_chroot(['dpkg-reconfigure', '-fnoninteractive', 'tzdata'])

    def _pkg_mark_auto(self):
        # mark all packages except explicitly included ones as auto-installed
        res = self._exec_chroot(['aptitude', 'search', '-F', '%p', '~i'],
                                capture_output = True, text = True)
        packages_all  = set(res.stdout.split())
        packages_auto = packages_all - set(include_pkgs)

        cmdline = ['apt-mark', 'auto'] + (self._verbose == 0) * ['-qq'] + list(packages_auto)

        self._exec_chroot(cmdline)

    def _clean_ssh_server(self):
        etc_ssh_fd = os.open('ssh', os.O_DIRECTORY, dir_fd = self._etc_fd)

        # clear SSH host keys so they are not shared by all containers created
        # from this image
        try:
            for item in os.listdir(etc_ssh_fd):
                if fnmatch(item, 'ssh_host_*_key*'):
                    if self._verbose > 1:
                        sys.stderr.write('Removing SSH host key: %s\n' % item)
                    os.remove(item, dir_fd = etc_ssh_fd)
        finally:
            os.close(etc_ssh_fd)

    def run(self):
        self._debootstrap(self._args.release, self._root_path)

        self._etc_fd = os.open('etc', os.O_DIRECTORY, dir_fd = self._root_fd)

        self._clean_dev()

        # remove the cdebootstrap helper that blocks rc operations
        self._exec_chroot(['dpkg', '--remove', 'cdebootstrap-helper-rc.d'])

        # set up initscripts
        self._exec_chroot(['insserv', '-r'] + initscripts_disable)

        if self._args.locales:
            self._config_locales(self._args.locales)
        if self._args.timezone:
            self._config_tz(self._args.timezone)

        # write /etc/apt/sources.list
        with open_dirfd('apt/sources.list', 'w', dir_fd = self._etc_fd) as f:
            f.write(apt_sources_template.format(release = self._args.release))

        # write /etc/network/interfaces
        if self._args.interfaces:
            for iface in self._args.interfaces.split(','):
                with open_dirfd('network/interfaces.d/' + iface, 'w', dir_fd = self._etc_fd) as f:
                    f.write('auto %s\niface %s inet dhcp\n' % (iface, iface))

        # write /etc/hostname
        with open_dirfd('hostname', 'w', dir_fd = self._etc_fd) as f:
            f.write('LXC_NAME\n')

        # write /etc/hosts
        # cdebootstrap writes a minimal default, but we overwrite it
        with open_dirfd('hosts', 'w', dir_fd = self._etc_fd) as f:
            f.write(hosts_template.format(domain = self._args.domain))

        # cdebootstrap copies host's resolv.conf, get rid of it
        os.remove('resolv.conf', dir_fd = self._etc_fd)

        self._pkg_mark_auto()
        self._clean_ssh_server()

        # record extra files for which lxc-local performs template processing
        with open_dirfd('templates', 'w', dir_fd = self._meta_fd) as f:
            f.write(lxc_templates)

        with open_dirfd('config', 'w', dir_fd = self._meta_fd) as f:
            f.write("")

    def _exec_chroot(self, cmdline, **kwargs):
        if self._verbose > 1:
            sys.stderr.write('Executing in chroot: %s\n' % cmdline)

        # discard command's stdout/err on zero verbosity, as there's no general
        # way to make all of them quiet
        # failures should still be detected via return codes
        if (self._verbose == 0 and
            not any((it in kwargs for it in ('stdout', 'stderr', 'capture_output')))):
            kwargs['stderr'] = subprocess.DEVNULL
            kwargs['stdout'] = subprocess.DEVNULL

        return subprocess.run(['chroot', self._root_path] + cmdline, check = True, **kwargs)

parser = argparse.ArgumentParser(description = description)

parser.add_argument('release')
parser.add_argument('rootfs_dir')

parser.add_argument('-v', '--verbose', action = 'count', default = 0)

parser.add_argument('-l', '--locales',
    default = 'en_US.UTF-8 UTF-8,en_DK.UTF-8 UTF-8',
    help = 'comma-separated list of locales to generate (as in /etc/locale.gen)')
parser.add_argument('-t', '--timezone', default = 'Europe/Prague')
parser.add_argument('-d', '--domain', default = 'khirnov.net')
parser.add_argument('-i', '--interfaces', default = 'eth0',
    help = 'comma-separated list of network interfaces to set up')

args = parser.parse_args()

bootstrapper = Bootstrapper(args)

try:
    bootstrapper.run()
finally:
    bootstrapper.close()