Ejemplo n.º 1
0
Archivo: base.py Proyecto: mikicz/arca
    def get_requirements_information(self, path: Path) -> Tuple[RequirementsOptions, Optional[str]]:
        """
        Returns the information needed to install requirements for a repository - what kind is used and the hash
        of contents of the defining file.
        """
        if self.pipfile_location is not None:
            pipfile = path / self.pipfile_location / "Pipfile"
            pipfile_lock = path / self.pipfile_location / "Pipfile.lock"

            pipfile_exists = pipfile.exists()
            pipfile_lock_exists = pipfile_lock.exists()

            if pipfile_exists and pipfile_lock_exists:
                option = RequirementsOptions.pipfile
                return option, self.hash_file_contents(option, pipfile_lock)
            elif pipfile_exists:
                raise BuildError("Only the Pipfile is included in the repository, Arca does not support that.")
            elif pipfile_lock_exists:
                raise BuildError("Only the Pipfile.lock file is include in the repository, Arca does not support that.")

        if self.requirements_location:
            requirements_file = path / self.requirements_location

            if requirements_file.exists():
                option = RequirementsOptions.requirements_txt
                return option, self.hash_file_contents(option, requirements_file)

        return RequirementsOptions.no_requirements, None
Ejemplo n.º 2
0
    def __init__(self, result: Union[str, Dict[str, Any]]) -> None:
        if isinstance(result, (str, bytes, bytearray)):
            output = result
            try:
                result = json.loads(result)
            except ValueError:
                raise BuildError(
                    "The build failed (the output was corrupted, "
                    "possibly by the callable printing something)",
                    extra_info={"output": output})

        if not isinstance(result, dict):
            raise BuildError(
                "The build failed (the value returned from the runner was not valid)"
            )

        if not result.get("success"):
            reason = "Task failed"
            if result.get("reason") == "corrupted_definition":
                reason = "Task failed because the definition was corrupted."
            elif result.get("reason") == "import":
                reason = "Task failed beucase the entry point could not be imported"

            raise BuildError(reason,
                             extra_info={"traceback": result.get("error")})

        #: The output of the task
        self.output = result.get("result")
Ejemplo n.º 3
0
Archivo: base.py Proyecto: mikicz/arca
    def run(self, repo: str, branch: str, task: Task, git_repo: Repo, repo_path: Path) -> Result:
        """
        Gets a path to a Python executable by calling the abstract method
        :meth:`get_image_for_repo <BaseRunInSubprocessBackend.get_image_for_repo>`
        and runs the task using :class:`subprocess.Popen`

        See :meth:`BaseBackend.run <arca.BaseBackend.run>` to see arguments description.
        """
        python_path = self.get_or_create_environment(repo, branch, git_repo, repo_path)

        task_filename, task_json = self.serialized_task(task)

        task_path = Path(self._arca.base_dir, "tasks", task_filename)
        task_path.parent.mkdir(parents=True, exist_ok=True)
        task_path.write_text(task_json)

        logger.info("Stored task definition at %s", task_path)

        out_output = ""
        err_output = ""

        cwd = str(repo_path / self.cwd)

        logger.info("Running at cwd %s", cwd)

        try:
            logger.debug("Running with python %s", python_path)

            process = subprocess.Popen([python_path,
                                        str(self.RUNNER),
                                        str(task_path.resolve())],
                                       stdout=subprocess.PIPE,
                                       stderr=subprocess.PIPE,
                                       cwd=cwd)

            try:
                out_stream, err_stream = process.communicate(timeout=task.timeout)
            except subprocess.TimeoutExpired:
                process.kill()
                raise BuildTimeoutError(f"The task timeouted after {task.timeout} seconds.")

            out_output = out_stream.decode("utf-8")
            err_output = err_stream.decode("utf-8")

            logger.debug("stdout output from the command")
            logger.debug(out_output)

            return Result(out_output)
        except BuildError:  # can be raised by  :meth:`Result.__init__` or by timeout
            raise
        except Exception as e:
            logger.exception(e)
            raise BuildError("The build failed", extra_info={
                "exception": e,
                "out_output": out_output,
                "err_output": err_output,
            })
Ejemplo n.º 4
0
    def install_requirements(self, *, path: Optional[Path] = None, requirements: Optional[Iterable[str]] = None,
                             _action: str = "install"):
        """
        Installs requirements, either from a file or from a iterable of strings.

        :param path: :class:`Path <pathlib.Path>` to a ``requirements.txt`` file. Has priority over ``requirements``.
        :param requirements: A iterable of strings of requirements to install.
        :param _action: For testing purposes, can be either ``install`` or ``uninstall``

        :raise BuildError: If installing fails.
        :raise ValueError: If both ``file`` and ``requirements`` are undefined.
        :raise ValueError: If ``_action`` not ``install`` or ``uninstall``.
        """
        if _action not in ["install", "uninstall"]:
            raise ValueError(f"{_action} is invalid value for _action")

        cmd = [sys.executable, "-m", "pip", _action]

        if _action == "uninstall":
            cmd += ["-y"]

        if path is not None:
            cmd += ["-r", str(path)]
        elif requirements is not None:
            cmd += list(requirements)
        else:
            raise ValueError("Either path or requirements has to be provided")

        logger.info("Installing requirements with command: %s", cmd)

        process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

        try:
            out_stream, err_stream = process.communicate(timeout=self.requirements_timeout)
        except subprocess.TimeoutExpired:
            process.kill()
            raise BuildTimeoutError(f"Installing of requirements timeouted after {self.requirements_timeout} seconds.")

        out_stream = out_stream.decode("utf-8")
        err_stream = err_stream.decode("utf-8")

        logger.debug("Return code is %s", process.returncode)
        logger.debug(out_stream)
        logger.debug(err_stream)

        if process.returncode:
            raise BuildError(f"Unable to {_action} requirements from the target repository", extra_info={
                "out_stream": out_stream,
                "err_stream": err_stream,
                "returncode": process.returncode
            })
Ejemplo n.º 5
0
    def check_docker_access(self):
        """ Creates a :class:`DockerClient <docker.client.DockerClient>` for the instance and checks the connection.

        :raise BuildError: If docker isn't accessible by the current user.
        """
        try:
            if self.client is None:
                self.client = docker.from_env()
            self.client.ping(
            )  # check that docker is running and user is permitted to access it
        except ConnectionError as e:
            logger.exception(e)
            raise BuildError(
                "Docker is not running or the current user doesn't have permissions to access docker."
            )
Ejemplo n.º 6
0
    def ensure_vm_running(self, vm_location):
        """ Gets or creates a Vagrantfile in ``vm_location`` and calls ``vagrant up`` if the VM is not running.
        """
        import vagrant

        if self.vagrant is None:
            vagrant_file = vm_location / "Vagrantfile"
            if not vagrant_file.exists():
                self.init_vagrant(vagrant_file)

            self.vagrant = vagrant.Vagrant(vm_location,
                                           quiet_stdout=self.quiet,
                                           quiet_stderr=self.quiet,
                                           out_cm=self.log_cm,
                                           err_cm=self.log_cm)

        status = [x for x in self.vagrant.status() if x.name == "default"][0]

        if status.state != "running":
            try:
                self.vagrant.up()
            except subprocess.CalledProcessError:
                raise BuildError("Vagrant VM couldn't up launched. See output for details.")
Ejemplo n.º 7
0
    def get_or_create_venv(self, path: Path) -> Path:
        """
        Gets the location of  the virtualenv from :meth:`get_virtualenv_path`, checks if it exists already,
        creates it and installs requirements otherwise. The virtualenvs are stored in a folder based
        on the :class:`Arca` ``base_dir`` setting.

        :param path: :class:`Path <pathlib.Path>` to the cloned repository.
        """
        requirements_option, requirements_hash = self.get_requirements_information(
            path)

        venv_path = self.get_virtualenv_path(requirements_option,
                                             requirements_hash)

        if not venv_path.exists():
            logger.info(f"Creating a venv in {venv_path}")
            builder = EnvBuilder(with_pip=True)
            builder.create(venv_path)

            shell = False
            cmd = None
            cwd = None

            if requirements_option == RequirementsOptions.pipfile:
                cmd = [
                    "source", (str(venv_path / "bin" / "activate")), "&&",
                    "pipenv", "install", "--deploy", "--ignore-pipfile"
                ]

                cmd = " ".join(cmd)

                cwd = path / self.pipfile_location
                shell = True
            elif requirements_option == RequirementsOptions.requirements_txt:
                requirements_file = path / self.requirements_location

                logger.debug("Requirements file:")
                logger.debug(requirements_file.read_text())
                logger.info("Installing requirements from %s",
                            requirements_file)

                cmd = [
                    str(venv_path / "bin" / "python3"), "-m", "pip", "install",
                    "-r",
                    shlex.quote(str(requirements_file))
                ]

            if cmd is not None:
                logger.info("Running Popen cmd %s, with shell %s", cmd, shell)

                process = subprocess.Popen(cmd,
                                           stdout=subprocess.PIPE,
                                           stderr=subprocess.PIPE,
                                           shell=shell,
                                           cwd=cwd)

                try:
                    out_stream, err_stream = process.communicate(
                        timeout=self.requirements_timeout)
                except subprocess.TimeoutExpired:
                    process.kill()
                    logger.warning(
                        "The install command timed out, deleting the virtualenv"
                    )
                    shutil.rmtree(str(venv_path), ignore_errors=True)

                    raise BuildTimeoutError(
                        f"Installing of requirements timeouted after "
                        f"{self.requirements_timeout} seconds.")

                out_stream = out_stream.decode("utf-8")
                err_stream = err_stream.decode("utf-8")

                logger.debug("Return code is %s", process.returncode)
                logger.debug(out_stream)
                logger.debug(err_stream)

                if process.returncode:
                    logger.warning(
                        "The install command failed, deleting the virtualenv")
                    shutil.rmtree(str(venv_path), ignore_errors=True)
                    raise BuildError("Unable to install requirements.txt",
                                     extra_info={
                                         "out_stream": out_stream,
                                         "err_stream": err_stream,
                                         "returncode": process.returncode
                                     })

            else:
                logger.info(
                    "Requirements file not present in repo, empty venv it is.")
        else:
            logger.info(f"Venv already exists in {venv_path}")

        return venv_path
Ejemplo n.º 8
0
    def run(self, repo: str, branch: str, task: Task, git_repo: Repo,
            repo_path: Path) -> Result:
        """ Gets or builds an image for the repo, gets or starts a container for the image and runs the script.

        :param repo: Repository URL
        :param branch: Branch ane
        :param task: :class:`Task` to run.
        :param git_repo: :class:`Repo <git.repo.base.Repo>` of the cloned repository.
        :param repo_path: :class:`Path <pathlib.Path>` to the cloned location.
        """
        self.check_docker_access()

        container_name = self.get_container_name(repo, branch, git_repo)

        container = self.container_running(container_name)
        if container is None:
            image = self.get_image_for_repo(repo, branch, git_repo, repo_path)

            container = self.start_container(image, container_name, repo_path)

        task_filename, task_json = self.serialized_task(task)

        container.put_archive(
            "/srv/scripts", self.tar_task_definition(task_filename, task_json))

        res = None

        try:
            command = ["timeout"]

            if self.inherit_image:
                if self.alpine_inherited or b"Alpine" in container.exec_run(
                    ["cat", "/etc/issue"], tty=True).output:
                    self.alpine_inherited = True
                    command = ["timeout", "-t"]

            command += [
                str(task.timeout), "python", "/srv/scripts/runner.py",
                f"/srv/scripts/{task_filename}"
            ]

            logger.debug("Running command %s", " ".join(command))

            res = container.exec_run(command, tty=True)

            # 124 is the standard, 143 on alpine
            if res.exit_code in {124, 143}:
                raise BuildTimeoutError(
                    f"The task timeouted after {task.timeout} seconds.")

            return Result(res.output)
        except BuildError:  # can be raised by  :meth:`Result.__init__`
            raise
        except Exception as e:
            logger.exception(e)
            if res is not None:
                logger.warning(res.output)

            raise BuildError("The build failed",
                             extra_info={
                                 "exception": e,
                                 "output": res if res is None else res.output
                             })
        finally:
            if not self.keep_container_running:
                container.kill(signal.SIGKILL)
            else:
                self._containers.add(container)
Ejemplo n.º 9
0
    def get_or_build_image(self,
                           name: str,
                           tag: str,
                           dockerfile: Union[str, Callable[..., str]],
                           *,
                           pull=True,
                           build_context: Optional[Path] = None):
        """
        A proxy for commonly built images, returns them from the local system if they exist, tries to pull them if
        pull isn't disabled, otherwise builds them by the definition in ``dockerfile``.

        :param name: Name of the image
        :param tag: Image tag
        :param dockerfile: Dockerfile text or a callable (no arguments) that produces Dockerfile text
        :param pull: If the image is not present locally, allow pulling from registry (default is ``True``)
        :param build_context: A path to a folder. If it's provided, docker will build the image in the context
            of this folder. (eg. if ``ADD`` is needed)
        """
        if self.image_exists(name, tag):
            logger.info("Image %s:%s exists", name, tag)
            return

        elif pull:
            logger.info("Trying to pull image %s:%s", name, tag)

            try:
                self.client.images.pull(name, tag=tag)
                logger.info("The image %s:%s was pulled from registry", name,
                            tag)
                return
            except docker.errors.APIError:
                logger.info(
                    "The image %s:%s can't be pulled, building locally.", name,
                    tag)

        if callable(dockerfile):
            dockerfile = dockerfile()

        try:
            if build_context is None:
                fileobj = BytesIO(bytes(
                    dockerfile, "utf-8"))  # required by the docker library

                self.client.images.build(fileobj=fileobj, tag=f"{name}:{tag}")
            else:
                dockerfile_file = build_context / "dockerfile"
                dockerfile_file.write_text(dockerfile)

                self.client.images.build(path=str(build_context.resolve()),
                                         dockerfile=dockerfile_file.name,
                                         tag=f"{name}:{tag}")

                dockerfile_file.unlink()
        except docker.errors.BuildError as e:
            for line in e.build_log:
                if isinstance(line, dict) and line.get(
                        "errorDetail") and line["errorDetail"].get("code") in {
                            124, 143
                        }:
                    raise BuildTimeoutError(
                        f"Installing of requirements timeouted after "
                        f"{self.requirements_timeout} seconds.")

            logger.exception(e)

            raise BuildError(
                "Building docker image failed, see extra info for details.",
                extra_info={"build_log": e.build_log})
Ejemplo n.º 10
0
    def run(self, repo: str, branch: str, task: Task, git_repo: Repo, repo_path: Path):
        """ Starts up a VM, builds an docker image and gets it to the VM, runs the script over SSH, returns result.
            Stops the VM if ``keep_vm_running`` is not set.
        """
        from fabric import api
        from fabric.exceptions import CommandTimeout

        # start up or get running VM
        vm_location = self.get_vm_location()
        self.ensure_vm_running(vm_location)
        logger.info("Running with VM located at %s", vm_location)

        # pushes the image to the registry so it can be pulled in the VM
        self.check_docker_access()  # init client
        self.get_image_for_repo(repo, branch, git_repo, repo_path)

        # getting things needed for execution over SSH
        image_tag = self.get_image_tag(self.get_requirements_file(repo_path), self.get_dependencies())
        image_name = self.use_registry_name

        task_filename, task_json = self.serialized_task(task)
        (vm_location / task_filename).write_text(task_json)

        container_name = self.get_container_name(repo, branch, git_repo)

        # setting up Fabric
        api.env.hosts = [self.vagrant.user_hostname_port()]
        api.env.key_filename = self.vagrant.keyfile()
        api.env.disable_known_hosts = True  # useful for when the vagrant box ip changes.
        api.env.abort_exception = BuildError  # raises SystemExit otherwise
        api.env.shell = "/bin/sh -l -c"
        if self.quiet:
            api.output.everything = False
        else:
            api.output.everything = True

        # executes the task
        try:
            res = api.execute(self.fabric_task,
                              container_name=container_name,
                              definition_filename=task_filename,
                              image_name=image_name,
                              image_tag=image_tag,
                              repository=str(repo_path.relative_to(Path(self._arca.base_dir).resolve() / 'repos')),
                              timeout=task.timeout)

            return Result(res[self.vagrant.user_hostname_port()].stdout)
        except CommandTimeout:
            raise BuildTimeoutError(f"The task timeouted after {task.timeout} seconds.")
        except BuildError:  # can be raised by  :meth:`Result.__init__`
            raise
        except Exception as e:
            logger.exception(e)
            raise BuildError("The build failed", extra_info={
                "exception": e
            })
        finally:
            # stops or destroys the VM if it should not be kept running
            if not self.keep_vm_running:
                if self.destroy:
                    self.vagrant.destroy()
                    shutil.rmtree(self.vagrant.root, ignore_errors=True)
                    self.vagrant = None
                else:
                    self.vagrant.halt()
Ejemplo n.º 11
0
    def get_or_create_venv(self, path: Path) -> Path:
        """
        Gets the name of the virtualenv from :meth:`get_virtualenv_name`, checks if it exists already,
        creates it and installs requirements otherwise. The virtualenvs are stored in a folder based
        on the :class:`Arca` ``base_dir`` setting.

        :param path: :class:`Path <pathlib.Path>` to the cloned repository.
        """
        requirements_file = self.get_requirements_file(path)
        venv_name = self.get_virtualenv_name(requirements_file)

        venv_path = Path(self._arca.base_dir) / "venvs" / venv_name

        if not venv_path.exists():
            logger.info(f"Creating a venv in {venv_path}")
            builder = EnvBuilder(with_pip=True)
            builder.create(venv_path)

            if requirements_file is not None:

                logger.debug("Requirements file:")
                logger.debug(requirements_file.read_text())
                logger.info("Installing requirements from %s",
                            requirements_file)

                process = subprocess.Popen([
                    str(venv_path / "bin" / "python3"), "-m", "pip", "install",
                    "-r",
                    str(requirements_file)
                ],
                                           stdout=subprocess.PIPE,
                                           stderr=subprocess.PIPE)

                try:
                    out_stream, err_stream = process.communicate(
                        timeout=self.requirements_timeout)
                except subprocess.TimeoutExpired:
                    process.kill()
                    shutil.rmtree(venv_path, ignore_errors=True)

                    raise BuildTimeoutError(
                        f"Installing of requirements timeouted after "
                        f"{self.requirements_timeout} seconds.")

                out_stream = out_stream.decode("utf-8")
                err_stream = err_stream.decode("utf-8")

                logger.debug("Return code is %s", process.returncode)
                logger.debug(out_stream)
                logger.debug(err_stream)

                if process.returncode:
                    venv_path.rmdir()
                    raise BuildError("Unable to install requirements.txt",
                                     extra_info={
                                         "out_stream": out_stream,
                                         "err_stream": err_stream,
                                         "returncode": process.returncode
                                     })

            else:
                logger.info(
                    "Requirements file not present in repo, empty venv it is.")
        else:
            logger.info(f"Venv already eixsts in {venv_path}")

        return venv_path