1#!/usr/bin/env python3
2
3import argparse
4import os
5import subprocess
6import sys
7import time
8
9SRC_MOUNT = "/root/src"
10STAGING_MOUNT = "/root/.floss"
11
12
13class FlossContainerRunner:
14    """Runs Floss build inside container."""
15
16    # Commands to run for build
17    BUILD_COMMANDS = [
18        # First run bootstrap to get latest code + create symlinks
19        [f'{SRC_MOUNT}/build.py', '--run-bootstrap', '--partial-staging'],
20
21        # Clean up any previous artifacts inside the volume
22        [f'{SRC_MOUNT}/build.py', '--target', 'clean'],
23
24        # Run normal code builder
25        [f'{SRC_MOUNT}/build.py', '--target', 'all'],
26
27        # Run tests
28        [f'{SRC_MOUNT}/build.py', '--target', 'test'],
29    ]
30
31    def __init__(self, workdir, rootdir, image_tag, volume_name, container_name, staging_dir, use_docker,
32                 use_pseudo_tty):
33        """ Constructor.
34
35        Args:
36            workdir: Current working directory (should be the script path).
37            rootdir: Root directory for Bluetooth.
38            image_tag: Tag for container image used for building.
39            volume_name: Volume name used for storing artifacts.
40            container_name: Name for running container instance.
41            staging_dir: Directory to mount for artifacts instead of using volume.
42            use_docker: Use docker binary if True (or podman when False).
43            use_pseudo_tty: Run container with pseudo tty if true.
44        """
45        self.workdir = workdir
46        self.rootdir = rootdir
47        self.image_tag = image_tag
48        self.container_binary = 'docker' if use_docker else 'podman'
49        self.env = os.environ.copy()
50
51        # Flags used by container exec:
52        # -i: interactive mode keeps STDIN open even if not attached
53        # -t: Allocate a pseudo-TTY (better user experience)
54        #     Only set if use_pseudo_tty is true.
55        self.container_exec_flags = '-it' if use_pseudo_tty else '-i'
56
57        # Name of running container
58        self.container_name = container_name
59
60        # Name of volume to write output.
61        self.volume_name = volume_name
62        # Staging dir where we send output instead of the volume.
63        self.staging_dir = staging_dir
64
65    def run_command(self, target, args, cwd=None, env=None, ignore_rc=False):
66        """ Run command and stream the output.
67        """
68        # Set some defaults
69        if not cwd:
70            cwd = self.workdir
71        if not env:
72            env = self.env
73
74        rc = 0
75        process = subprocess.Popen(args, cwd=cwd, env=env, stdout=subprocess.PIPE)
76        while True:
77            line = process.stdout.readline()
78            print(line.decode('utf-8'), end="")
79            if not line:
80                rc = process.poll()
81                if rc is not None:
82                    break
83
84                time.sleep(0.1)
85
86        if rc != 0 and not ignore_rc:
87            raise Exception("{} failed. Return code is {}".format(target, rc))
88
89    def _create_volume_if_needed(self):
90        # Check if the volume exists. Otherwise create it.
91        try:
92            subprocess.check_output([self.container_binary, 'volume', 'inspect', self.volume_name])
93        except:
94            self.run_command(self.container_binary + ' volume create',
95                             [self.container_binary, 'volume', 'create', self.volume_name])
96
97    def start_container(self):
98        """Starts the container with correct mounts."""
99        # Stop any previously started container.
100        self.stop_container(ignore_error=True)
101
102        # Create volume and create mount string
103        if self.staging_dir:
104            mount_output_volume = 'type=bind,src={},dst={}'.format(self.staging_dir, STAGING_MOUNT)
105        else:
106            # If not using staging dir, use the volume instead
107            self._create_volume_if_needed()
108            mount_output_volume = 'type=volume,src={},dst={}'.format(self.volume_name, STAGING_MOUNT)
109
110        # Mount the source directory
111        mount_src_dir = 'type=bind,src={},dst={}'.format(self.rootdir, SRC_MOUNT)
112
113        # Run the container image. It will run `tail` indefinitely so the container
114        # doesn't close and we can run `<container_binary> exec` on it.
115        self.run_command(self.container_binary + ' run', [
116            self.container_binary, 'run', '--name', self.container_name, '--mount', mount_output_volume, '--mount',
117            mount_src_dir, '-d', self.image_tag, 'tail', '-f', '/dev/null'
118        ])
119
120    def stop_container(self, ignore_error=False):
121        """Stops the container for build."""
122        self.run_command(self.container_binary + ' stop',
123                         [self.container_binary, 'stop', '-t', '1', self.container_name],
124                         ignore_rc=ignore_error)
125        self.run_command(self.container_binary + ' rm', [self.container_binary, 'rm', self.container_name],
126                         ignore_rc=ignore_error)
127
128    def do_build(self):
129        """Runs the basic build commands."""
130        # Start container before building
131        self.start_container()
132
133        try:
134            # Run all commands
135            for i, cmd in enumerate(self.BUILD_COMMANDS):
136                self.run_command(self.container_binary + ' exec #{}'.format(i),
137                                 [self.container_binary, 'exec', self.container_exec_flags, self.container_name] + cmd)
138        finally:
139            # Always stop container before exiting
140            self.stop_container()
141
142    def print_do_build(self):
143        """Prints the commands for building."""
144        container_exec = [self.container_binary, 'exec', self.container_exec_flags, self.container_name]
145        print('Normally, build would run the following commands: \n')
146        for cmd in self.BUILD_COMMANDS:
147            print(' '.join(container_exec + cmd))
148
149    def check_container_runnable(self):
150        try:
151            subprocess.check_output([self.container_binary, 'ps'], stderr=subprocess.STDOUT)
152        except subprocess.CalledProcessError as err:
153            if 'denied' in err.output.decode('utf-8'):
154                print('Run script as sudo')
155            else:
156                print('Unexpected error: {}'.format(err.output.decode('utf-8')))
157
158            return False
159
160        # No exception means container is ok
161        return True
162
163
164if __name__ == "__main__":
165    parser = argparse.ArgumentParser('Builder Floss inside container image.')
166    parser.add_argument('--only-start',
167                        action='store_true',
168                        default=False,
169                        help='Only start the container. Prints the commands it would have ran.')
170    parser.add_argument('--only-stop', action='store_true', default=False, help='Only stop the container and exit.')
171    parser.add_argument('--image-tag', default='floss:latest', help='Container image to use to build.')
172    parser.add_argument('--volume-tag',
173                        default='floss-out',
174                        help='Name of volume to use. This is where build artifacts will be stored by default.')
175    parser.add_argument('--staging-dir',
176                        default=None,
177                        help='Staging directory to use instead of volume. Build artifacts will be written here.')
178    parser.add_argument('--container-name',
179                        default='floss-container-runner',
180                        help='What to name the started container.')
181    parser.add_argument('--use-docker',
182                        action='store_true',
183                        default=False,
184                        help='Use flag to use Docker to build Floss. Defaults to using podman.')
185    parser.add_argument('--no-tty',
186                        action='store_true',
187                        default=False,
188                        help='Use flag to disable pseudo tty for docker container.')
189    args = parser.parse_args()
190
191    # cwd should be set to same directory as this script (that's where
192    # Dockerfile is kept).
193    workdir = os.path.dirname(os.path.abspath(sys.argv[0]))
194    rootdir = os.path.abspath(os.path.join(workdir, '../..'))
195
196    # Determine staging directory absolute path
197    staging = os.path.abspath(args.staging_dir) if args.staging_dir else None
198
199    fdr = FlossContainerRunner(workdir, rootdir, args.image_tag, args.volume_tag, args.container_name, staging,
200                               args.use_docker, not args.no_tty)
201
202    # Make sure container is runnable before continuing
203    if fdr.check_container_runnable():
204        # Handle some flags
205        if args.only_start:
206            fdr.start_container()
207            fdr.print_do_build()
208        elif args.only_stop:
209            fdr.stop_container()
210        else:
211            fdr.do_build()
212