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

project: introduce build-snaps #1518

Merged
merged 21 commits into from
Sep 5, 2017
Merged

Conversation

sergiusens
Copy link
Collaborator

Introduce build-snaps with support for advanced grammar.

Copy link
Contributor

@come-maiz come-maiz left a comment

Choose a reason for hiding this comment

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

I am eager to see this released.
I left a few comments. And this is missing the recording part. Do you plan to do it as part of this branch, or leave it for (me, probably) later?

@@ -276,6 +276,9 @@ properties:
stage-packages:
$ref: "#/definitions/grammar-array"
default: [] # For some reason this doesn't work if in the ref
build-snaps:
Copy link
Contributor

Choose a reason for hiding this comment

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

¡viva! this is the end of compiling go1.8 on every build.

Choose a reason for hiding this comment

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

go 1.9 now, already...

@@ -276,6 +276,9 @@ properties:
stage-packages:
$ref: "#/definitions/grammar-array"
default: [] # For some reason this doesn't work if in the ref
build-snaps:
$ref: "#/definitions/grammar-array"
default: [] # For some reason this doesn't work if in the ref
Copy link
Contributor

Choose a reason for hiding this comment

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

hum, I haven't seen this before. It's ok to leave a comment, but it needs more information.
What doesn't work, specifically? I guess this is a question for @kyrofa.

Copy link
Contributor

@kyrofa kyrofa Aug 31, 2017

Choose a reason for hiding this comment

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

When I initially wrote the grammar back in the day I wasn't sure what was happening, and decided it was a jsonschema quirk. However, now that you ask I looked into it with a fresh mind and I think I know what's actually happening: a jsonschema ref isn't expanded in any fancy way by the YAML (which is why I'd like to use a recursive YAML anchor for this, but jsonschema causes things to explode that way since it wants to walk the whole thing). This means that we can't actually access the chunk of YAML representing that bit of the schema very easily in code. Like we do here to expand defaults. So this is kind of an ugly hack to allow specifying the defaults to continue actually resulting in defaults. Thankfully that's the only field for which I imagine we'll need this hack.

@@ -89,6 +89,7 @@ def __init__(self, project_options=None):
if project_options is None:
project_options = snapcraft.ProjectOptions()

self.build_snaps = set()
self.build_tools = []
Copy link
Contributor

Choose a reason for hiding this comment

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

have I said how much I hate this build_tools? :D
I'm just saying it again to put myself pressure to fix it.

# 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/>.
Copy link
Contributor

Choose a reason for hiding this comment

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

missing empty line

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this I will indeed ignore ;-)


@classmethod
def is_valid_snap(cls, snap):
return cls(snap).is_valid()
Copy link
Contributor

Choose a reason for hiding this comment

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

I consider this very weird. A class method that makes an instance. I haven't seen it before, is it an accepted pattern?

I would prefer the caller to make the instance. Or, a little less preferable, to make is_valid_snap a module function instead of a class method.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is a common pattern, yes

self.get_store_snap_info()
return self._is_on_store

def get_local_snap_info(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason for not putting an _ in front of this one?

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 have no reason for the fact that it can be used outside even though it isn't right now.

@property
def on_store(self):
if self._is_on_store is None:
self.get_store_snap_info()
Copy link
Contributor

Choose a reason for hiding this comment

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

same comment about assigning _is_on_store

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This means I also need ancillary values for the actual info which I was trying to avoid.

if not self.on_store:
return dict()

return snap_store_info['result'][0]['channels']
Copy link
Contributor

Choose a reason for hiding this comment

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

Is that 0 because multiple results can be returned? It would be nice to explain that in a comment.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, I had it in a comment somewhere else before moving this to a class.

if self._original_channel:
snap_install_cmd.extend(['--channel', self._original_channel])
if self.is_classic:
snap_install_cmd.extend(['--classic'])
Copy link
Contributor

Choose a reason for hiding this comment

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

I now this would complicate the snapcraft.yaml, but I would prefer the user to declare if she wants to install the snap as classic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this is not what was agreed to on the forum 😉

try:
check_call(snap_refresh_cmd)
except CalledProcessError as install_error:
raise errors.SnapInstallError(snap_name=self.name,
Copy link
Contributor

Choose a reason for hiding this comment

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

A Refresh error would be more appropriate here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

got it

@sergiusens sergiusens force-pushed the build-snap branch 3 times, most recently from 2444f3c to c26600a Compare August 31, 2017 02:46
def __init__(self, parts, project_options, validator, build_tools,
snapcraft_yaml):
def __init__(self, parts, project_options, validator,
build_snaps, build_tools, snapcraft_yaml):
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 add a * here and use keywords?

if not snap_pkg.installed and snap_pkg.on_store:
snap_pkg.install()
elif snap_pkg.get_current_channel() != snap_pkg.channel:
snap_pkg.refresh()
Copy link
Contributor

Choose a reason for hiding this comment

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

This should also check if is_classic is the same and requires a refresh.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ah, right, for people who bounce back and forth; in general terms if installed once with classic you don't need to pass it on for subsequent installs

Copy link
Contributor

@kalikiana kalikiana Aug 31, 2017

Choose a reason for hiding this comment

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

To be clear, I'm talking about the case where snap foo has strict confinement and a new revision is a classic snap, or vice versa. Afair snapd doesn't change it automatically.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yeah, that is what I meant by bounce back and forth 😉

self.fail(
'This integration test cannot run if you already have the '
"'hello' package installed. Please uninstall it before "
'running this test.')
Copy link
Contributor

Choose a reason for hiding this comment

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

This should say "if you already have the 'hello' snap installed", otherwise it makes you think of apt or dnf. Also, print the command that does that.

snap_channel=self.channel)

def refresh(self):
"""Refreshes a snap onto a channel on the system."""
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be Refreshes a snap to the latest version, channel and confinement type because 1) it will update the snap if there's a new version 2) as per my comment on install_snaps confinement may have changed.

snap_pkg.refresh()


def _snap_command_requires_sudo():
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 unit test 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.

Yeah, unittest for this whole module coming today :-)

snap_install_cmd.extend(['snap', 'install', self.name])
if self._original_channel:
snap_install_cmd.extend(['--channel', self._original_channel])
if self.is_classic:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this is missing the TODO comment to make it a user choice on non ci/container environments

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this is done in the is_classic check now

confinment value and channel availability.

This information can also be used determine if a snap should be
installed or refreshed.
Copy link
Contributor

Choose a reason for hiding this comment

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

There should be a "to" in here: "This information can also be used to determine[...]"

return self._is_installed

@property
def on_store(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

On the store, or in the store?

snap_install_cmd = []
if _snap_command_requires_sudo():
snap_install_cmd = ['sudo']
snap_install_cmd.extend(['snap', 'install', self.name])
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there an advantage to shelling out as opposed to using the snapd API 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.

it is much more work as we'd need to take care of "uploading" the snap.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh wait, not for these snaps. Maybe not, let me look

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'll leave this for future work, I don't even know how stable that API is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

nor where it is documented

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, fair enough.

store_channels = self._get_store_channels()
return self.channel in store_channels.keys()

def install(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

What if the snap I want to use to build requires devmode? Are we choosing to not support that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

not at the moment, check the forum post on the subject

Copy link
Contributor

@kyrofa kyrofa Sep 1, 2017

Choose a reason for hiding this comment

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

I did. It says nothing about devmode.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

right, so it would need a discussion first, right now it shouldn't work. In all fairness, if we discuss this, I'd say it shouldn't just like we are not auto connecting interfaces.

def _get_parsed_snap(snap):
if '/' in snap:
snap_name = snap[:snap.find('/')]
snap_channel = snap[snap.find('/')+1:]
Copy link
Contributor

Choose a reason for hiding this comment

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

The two snap.find('/')s here are bothering me-- perhaps it should be saved in a variable?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ok, fair enough

@@ -32,7 +32,7 @@ name="$1"
lxc="/snap/bin/lxc"

echo "Starting the LXD container."
$lxc launch --ephemeral ubuntu:xenial "$name"
$lxc launch --ephemeral --config security.privileged=true ubuntu:xenial "$name"
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need privileged containers here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The commit that added this has the info, but here it is: https://bugs.launchpad.net/snapd/+bug/1709536

Copy link
Contributor

@kyrofa kyrofa Aug 31, 2017

Choose a reason for hiding this comment

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

Ah yes, I hit that myself. I fixed it by removing the Nice value from the systemd unit file though as opposed to making the container privileged. It's probably not a big deal, let's just keep an eye on the bug and change back to unprivileged once it's fixed. Can we add a FIXME comment or something, though, with a link to the bug?

snap_install_cmd.extend(['--channel', self._original_channel])
if self.is_classic:
# TODO make this a user explicit choice
snap_install_cmd.extend(['--classic'])
Copy link
Contributor

Choose a reason for hiding this comment

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

This should just be .append() no need to .extend() with a single item list

'--channel', self.channel])
if self.is_classic:
# TODO make this a user explicit choice
snap_refresh_cmd.extend(['--classic'])
Copy link
Contributor

Choose a reason for hiding this comment

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

More .extend() waste

def _get_store_snap_info(snap_name):
# This logic uses /v2/find returns an array of results, given that
# we do a strict search either 1 result or a 404 will be returned.
slug = '{}{}'.format('find?name=', urllib.parse.quote(snap_name, safe=''))
Copy link
Contributor

Choose a reason for hiding this comment

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

huh? why are you interpolating a fixed string?

'find?name={}'.format(...)` would be more intuitive

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 don't know 🙃

Copy link
Contributor

Choose a reason for hiding this comment

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

any reason for not using urllib.parse.urlencode here?

Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW using urllib.parse.quote like that results in unnecessary %-encoding of characters that don't need it. If you'd rather not use urlencode (maybe building a dict isn't to your taste?), just use urllib.parse.quote_plus which is the quote function for the query part of a URL.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

requests_unixsockets doesn't support the dict as params, but I guess you are implying that urlencode can do that so i'll take a look. The only one I sort of have problems with is urljoin.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

switched to urlencode on bec56ed

if current_channel == 'stable':
current_channel = 'latest/stable'
if current_channel in ('stable', 'candidate', 'beta', 'edge'):
current_channel = 'latest/{}'.format(current_channel)
Copy link
Contributor

Choose a reason for hiding this comment

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

This check won't catch branches, think build-snaps: go/hotfix-211. Perhaps instead of a whitelist of risks, just check to see if a track is specified (e.g. searching for /)?

return 'email: -'.encode()


class FakeSnapdServer(BaseHTTPRequestHandler):
Copy link
Contributor

Choose a reason for hiding this comment

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

Having @chipaca look this fake over to make sure everything is sane would be helpful.

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 sort of asked him the necessary questions about API and showed him the original implementation so I don't think he is needed her @kyrofa

Copy link
Contributor

Choose a reason for hiding this comment

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

can confirm, was sort of asked.

return (request, client_address)


class FakeSnapd(fixtures.Fixture):
Copy link
Contributor

Choose a reason for hiding this comment

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

I like your new FakeSnapd a lot. But we already have a FakeSnapd fixture in snapcraft/tests/fixture_setup.py so please merge the two.

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 would rather not do it here as it would require substantial changes. I'll do it in a follow up PR though.

self.fake_snap_command.refresh_success = False
self.assertRaises(errors.SnapRefreshError, snap_pkg.refresh)

def test_bundle_install(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: the term "bundle" confused me. I think test_install_multiple would be much clearer.

self.assertThat(snap_pkg.in_store, Is(self.expected))


class SnapPackageIsClasicTest(SnapPackageBaseTestCase):
Copy link
Contributor

Choose a reason for hiding this comment

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

Typo: SnapPackageIsClassicTest should have two s.

snap_pkg = snaps.SnapPackage('fake-snap/classic/stable')
snap_pkg.install()
self.assertThat(self.fake_snap_command.calls, Equals([
['snap', 'whoami'],
Copy link
Contributor

Choose a reason for hiding this comment

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

There's a test missing for the case of a logged in user where sudo won't be used.

Copy link
Contributor

@come-maiz come-maiz left a comment

Choose a reason for hiding this comment

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

Looks very good to me.
I tested it with a few real snaps. Now we need for the go plugin to not install golang-go from the archive.

I'm a couple of integration tests that install a snap with track and risk.

class BuildSnapGrammarTestCase(testscenarios.WithScenarios,
integration_tests.TestCase):

scenarios = _construct_scenarios()
Copy link
Contributor

Choose a reason for hiding this comment

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

yeah, I would use multiply here, but if this makes you happy, ok :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I saw multiply, this feels much more readable IMO, the other options was as @sparkiegeek mentioned, subTest but iirc, testscenarios had issues with that, right?

Copy link
Contributor

Choose a reason for hiding this comment

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


It uses information provided by snapd implicitly referring to the local
and remote stores to obtain information about the snap, such as its
confinment value and channel availability.
Copy link
Contributor

Choose a reason for hiding this comment

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

typo: confinement.

except subprocess.CalledProcessError as e:
self.fail("unable to remove 'hello': {}".format(e.output))

def _hello_is_installed(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

_is_hello_installed should be the name of this function.

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 replicated what was in test_build_package_grammar. Let's change this in a different PR.

self.calls.append(cmd)
return self._fake_snap_command(cmd, *args, **kwargs)
else:
return original(cmd, *args, **kwargs)
Copy link
Contributor

Choose a reason for hiding this comment

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

Hey, I like this! ;)

elif cmd == 'refresh' and not self.refresh_success:
raise subprocess.CalledProcessError(returncode=1, cmd=cmd)
elif cmd == 'whoami':
return 'email: {}'.format(self._email).encode()
Copy link
Contributor

Choose a reason for hiding this comment

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

I've been reflecting upon these fakes. I think things would be a lot easier if we always called check_output, even when we don't care about the output. That way we only have to make one patch. And if there is only one patch, and in good theory the test should only call the patched function once, then the test itself could inject the exception to raise. That would be clearer to me. But well, all random thoughts for future improvement, we can discuss about the fake tomorrow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Using check_output only will not stream the output to stdout which is a feature. If you must, we should migrate to our own runner based on Popen

return 'email: {}'.format(self._email).encode()


class FakeSnapdServer(BaseHTTPRequestHandler):
Copy link
Contributor

Choose a reason for hiding this comment

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

I would move this fake to snapcraft/tests/fake_servers/snapd.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My next step after this PR is to refactor fixtures_setup and others to make it more readable, I was lost inside those modules while trying to make out some structure from it

params = {'channel': 'track/stable/branch'}
elif parsed_url.path.endswith('/fake-snap-edge'):
status_code = 200
params = {'channel': 'edge'}
Copy link
Contributor

Choose a reason for hiding this comment

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

This unending stream of elifs caused many problems when we left the fake servers evolve. That's why on the new ones we are using pyramid to route the requests. This is not a lot better, just a little, but it will help for when we are finally able to use https://github.com/pretenders/pretenders

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 don't like complicating things at the start though.

@@ -25,9 +25,12 @@ lxc="/snap/bin/lxc"

"$script_path/setup_lxd.sh"
"$script_path/run_lxd_container.sh" snap-builder
# Workaround for
# - Setup snap "core" (2462) security profiles (cannot reload udev rules: exit status 2
Copy link
Contributor

Choose a reason for hiding this comment

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

is there a bug for this one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

no idea, I was told it was known

dict(snap='fake-snap-branch/candidate/branch',
expected='latest/candidate/branch')),
('track/stable/branch',
dict(snap='fake-snap-track-stable-branch/trakc/stable/branch',
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a typo here: trakc/track.
This is one of the good reasons to migrate out of those hard-coded fake servers.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed, but in the case of this test fixture the request for the future is never made, if I had called it, I would of gotten an error (like install or is_classic)

Copy link
Contributor

@come-maiz come-maiz left a comment

Choose a reason for hiding this comment

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

+1 from the phone, like kids do \•/

@sergiusens sergiusens merged commit 69dceb8 into canonical:master Sep 5, 2017
@sergiusens sergiusens deleted the build-snap branch September 5, 2017 02:13
@sergiusens sergiusens mentioned this pull request Sep 11, 2017
kalikiana pushed a commit to kalikiana/snapcraft that referenced this pull request Sep 21, 2017
Introduce build-snaps with support for advanced grammar.
Fixes canonical#1442
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants