Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

build_providers: new build provider using multipass #2100

Merged
merged 17 commits into from Apr 26, 2018
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions setup.py
Expand Up @@ -35,6 +35,8 @@
'snapcraft.integrations',
'snapcraft.internal',
'snapcraft.internal.cache',
'snapcraft.internal.build_providers',
'snapcraft.internal.build_providers._multipass',
'snapcraft.internal.deltas',
'snapcraft.internal.lifecycle',
'snapcraft.internal.lxd',
Expand Down
27 changes: 21 additions & 6 deletions snapcraft/cli/env.py
Expand Up @@ -15,9 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from distutils import util
from typing import List

from . import echo
from snapcraft.internal import errors
from snapcraft.formatting_utils import humanize_list


class BuilderEnvironmentConfig:
Expand All @@ -31,6 +33,7 @@ class BuilderEnvironmentConfig:

- host: the host will drive the build.
- lxd: the host will setup a container to drive the build.
- multipass: a vm driven by multipass will be created to drive the build.

Use of the lxd value is equivalent to setting the now deprecated
SNAPCRAFT_CONTAINER_BUILDS environment variable to a value that
Expand All @@ -39,7 +42,19 @@ class BuilderEnvironmentConfig:
results in an error.
"""

def __init__(self) -> None:
def __init__(self, *, default='host',
additional_providers: List[str]=None) -> None:
"""Instantiate a BuildEnvironmentConfig.

:param str default: the default provider to use among the list of valid
ones.
:param str additional_providers: Additional providers allowed in the
environment.
"""
valid_providers = ['host', 'lxd']
if additional_providers is not None:
valid_providers.extend(additional_providers)

use_lxd = None
container_builds = os.environ.get('SNAPCRAFT_CONTAINER_BUILDS')
if container_builds:
Expand All @@ -64,12 +79,12 @@ def __init__(self) -> None:
if use_lxd:
build_provider = 'lxd'
elif not build_provider:
echo.warning('Using the host as the build environment.')
build_provider = 'host'
# TODO add multipass
elif build_provider not in ['host', 'lxd']:
build_provider = default
elif build_provider not in valid_providers:
raise errors.SnapcraftEnvironmentError(
'SNAPCRAFT_BUILD_ENVIRONMENT must be one of: host or lxd.')
'SNAPCRAFT_BUILD_ENVIRONMENT must be one of: {}.'.format(
humanize_list(items=valid_providers, conjunction='or')))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a proper error class for this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think it is necessary to create an error class for a feature flag variable

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about testing? Having #1705 in mind where we want to get rid of messages in tests... or are you suggesting to not test this case?


self.provider = build_provider
self.is_host = build_provider == 'host'
self.is_lxd = build_provider == 'lxd'
13 changes: 10 additions & 3 deletions snapcraft/cli/lifecycle.py
Expand Up @@ -195,12 +195,11 @@ def clean(parts, step, **kwargs):
@click.option('--debug', is_flag=True,
help='Shells into the environment if the build fails.')
def cleanbuild(remote, debug, **kwargs):
"""Create a snap using a clean environment managed by lxd.
"""Create a snap using a clean environment managed by a build provider.

\b
Examples:
snapcraft cleanbuild
snapcraft cleanbuild --output

The cleanbuild command requires a properly setup lxd environment that
can connect to external networks. Refer to the "Ubuntu Desktop and
Expand All @@ -211,8 +210,16 @@ def cleanbuild(remote, debug, **kwargs):
If using a remote, a prior setup is required which is described on:
https://linuxcontainers.org/lxd/getting-started-cli/#multiple-hosts
"""
# cleanbuild is a special snow flake, while all the other commands
# would work with the host as the build_provider it makes little
# sense in this scenario.
build_environment = env.BuilderEnvironmentConfig(
default='lxd', additional_providers=['multipass'])
project_options = get_project_options(**kwargs, debug=debug)
lifecycle.cleanbuild(project_options, remote)
lifecycle.cleanbuild(project=project_options,
echoer=echo,
remote=remote,
build_environment=build_environment)


if __name__ == '__main__':
Expand Down
19 changes: 19 additions & 0 deletions snapcraft/internal/build_providers/__init__.py
@@ -0,0 +1,19 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2018 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from . import errors # noqa: F401
from ._factory import get_provider_for # noqa: F401
from ._multipass import Multipass # noqa: F401
97 changes: 97 additions & 0 deletions snapcraft/internal/build_providers/_base_provider.py
@@ -0,0 +1,97 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2018 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import abc
import shlex
from typing import Callable

import petname


class Provider():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is heading the right direction, but I'm losing track of what is and isn't public API ("public" defined to be "meant to be called by a user of Provider). For example, run and launch are both public, but I don't believe they're actually meant to be called by anything except Provider, right?


def __init__(self, *, project, echoer) -> None:
self.project = project
self.echoer = echoer
# Once https://github.com/CanonicalLtd/multipass/issues/220 is
# closed we can prepend snapcraft- again.
self.instance_name = petname.Generate(2, '-')
self.project_dir = shlex.quote(project.info.name)

if project.info.version:
self.snap_filename = '{}_{}_{}.snap'.format(
project.info.name, project.info.version, project.deb_arch)
else:
self.snap_filename = '{}_{}.snap'.format(
project.info.name, project.deb_arch)

def __enter__(self):
self.create()
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self.destroy()

@property
@abc.abstractmethod
def run(self) -> Callable[[str], None]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems odd to have this as a property, especially considering that it's actually a callable, so it LOOKS like a method when used. Why not just make it a method, and the implementer can just pass it on to their command instance?

"""Return a callable to run commands on the the instance.

:returns: a callable which takes one argument, a list, with the
command to run.
"""

@property
@abc.abstractmethod
def launch(self) -> Callable[[], None]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, feels like it should be a method.

"""Return a callable that can be used to launch an instance."""

@abc.abstractmethod
def create(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing -> None for both of these.

"""Provider steps needed to create a fully functioning environemnt."""

@abc.abstractmethod
def destroy(self):
"""Provider steps needed to ensure the instance is destroyed.

This method should be safe to call multiple times and do nothing
if the instance to destroy is already destroyed.
"""

@abc.abstractmethod
def provision_project(self, tarball: str) -> None:
"""Provider steps needed to copy project assests to the instance."""

@abc.abstractmethod
def build_project(self) -> None:
"""Provider steps needed build the project on the instance."""

@abc.abstractmethod
def retrieve_snap(self) -> str:
"""
Provider steps needed to retrieve the built snap from the instance.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please document what this should return?

"""

def launch_instance(self):
self.echoer.info('Creating a build environment named {!r}'.format(
self.instance_name))
self.launch()

def setup_snapcraft(self):
self.echoer.info('Setting up snapcraft in {!r}'.format(
self.instance_name))
install_cmd = ['sudo', 'snap', 'install', 'snapcraft', '--classic']
self.run(install_cmd)
33 changes: 33 additions & 0 deletions snapcraft/internal/build_providers/_factory.py
@@ -0,0 +1,33 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2018 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from typing import TYPE_CHECKING

from . import errors
from ._multipass import Multipass

if TYPE_CHECKING:
from typing import Type # noqa: F401

from ._base_provider import Provider # noqa: F401


def get_provider_for(provider_name: str) -> 'Type[Provider]':
"""Returns a Type that can build with provider_name."""
if provider_name == 'multipass':
return Multipass
else:
raise errors.ProviderNotSupportedError(provider=provider_name)
18 changes: 18 additions & 0 deletions snapcraft/internal/build_providers/_multipass/__init__.py
@@ -0,0 +1,18 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2018 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from ._multipass import Multipass # noqa: F401
from ._multipass_command import MultipassCommand # noqa: F401
79 changes: 79 additions & 0 deletions snapcraft/internal/build_providers/_multipass/_instance_info.py
@@ -0,0 +1,79 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2018 Canonical Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import json
from typing import Type

from snapcraft.internal.build_providers import errors


class InstanceInfo:

@classmethod
def from_json(cls: Type['InstanceInfo'], *, instance_name: str,
json_info: str) -> 'InstanceInfo':
"""Create an InstanceInfo from json_info retrieved from multipass.

:param str instance_name: the name of the instance.
:param str json_info: a json formatted string with the structure
that would follow the output of a json formatted
multipass info command.
:returns: an InstanceInfo.
:rtype: InstanceInfo
:raises snapcraft.internal.build_providers.ProviderInfoDataKeyError:
if the instance name cannot be found in the given json or if a
required key is missing from that data structure for the instance.
"""
try:
json_data = json.loads(json_info)
except json.decoder.JSONDecodeError as decode_error:
raise errors.ProviderBadDataError(
provider_name='multipass',
data=json_info) from decode_error
try:
instance_info = json_data['info'][instance_name]
except KeyError as missing_key:
raise errors.ProviderInfoDataKeyError(
provider_name='multipass',
missing_key=str(missing_key),
data=json_data) from missing_key
try:
return cls(name=instance_name,
state=instance_info['state'],
image_release=instance_info['image_release'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gives you the release of the image from which this instance got launched. Is this enough? OTOH release is only currently available when the instance is running, so probably best to use that anyway.

except KeyError as missing_key:
raise errors.ProviderInfoDataKeyError(
provider_name='multipass',
missing_key=str(missing_key),
data=instance_info) from missing_key

def __init__(self, *, name: str, state: str, image_release: str) -> None:
"""Initialize an InstanceInfo.

:param str name: the instance name.
:param str state: the state of the instance which can be any one of
RUNNING, STOPPED, DELETED.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a perfect use-case for enums. Thoughts?

:param str image_release: the Operating System release string for the
image.
"""
# We do not check for validity of state given that new states could
# be introduced.
self.name = name
self.state = state
self.image_release = image_release

def is_stopped(self):
return self.state == 'STOPPED'