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

Add support for hooks. #1026

Merged
merged 4 commits into from Jan 5, 2017
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions demos/hooks/snap/hooks/configure
@@ -0,0 +1,9 @@
#!/bin/sh

output=$(snapctl get fail)
if [ "$output" = "true" ]; then
echo "Failing as requested."
exit 1
fi

echo "I'm the configure hook!"
10 changes: 10 additions & 0 deletions demos/hooks/snapcraft.yaml
@@ -0,0 +1,10 @@
name: hooks
version: 1.0
summary: Snap containing a configure hook
description: This snap contains a configure hook that fails upon command
confinement: strict
grade: stable

parts:
nil-part:
plugin: nil
16 changes: 16 additions & 0 deletions demos/pyhooks/configure_hook.py
@@ -0,0 +1,16 @@
#!/usr/bin/env python3

import subprocess
import sys


def main():
output = subprocess.check_output(['snapctl', 'get', 'fail']).decode('utf8').strip()
if output == 'true':
print('Failing as requested.')
sys.exit(1)

print("I'm the configure hook!")

if __name__ == '__main__':
main()
15 changes: 15 additions & 0 deletions demos/pyhooks/snapcraft.yaml
@@ -0,0 +1,15 @@
name: pyhooks
version: 1.0
confinement: strict
grade: stable
summary: Snap containing a configure hook written in python
description: |
This snap contains a configure hook written in python that fails upon
command

parts:
hook:
plugin: python
install: |
mkdir -p $SNAPCRAFT_PART_INSTALL/snap/hooks
cp configure_hook.py $SNAPCRAFT_PART_INSTALL/snap/hooks/configure
3 changes: 3 additions & 0 deletions integration_tests/snaps/hooks/another-hook/another-hook
@@ -0,0 +1,3 @@
#!/bin/sh

echo "I'm another hook. I'm not actually valid, but snapcraft doesn't care."
3 changes: 3 additions & 0 deletions integration_tests/snaps/hooks/configure-again/configure
@@ -0,0 +1,3 @@
#!/bin/sh

echo "I'm another configure hook. I should be overwritten by the other one."
3 changes: 3 additions & 0 deletions integration_tests/snaps/hooks/snap/hooks/configure
@@ -0,0 +1,3 @@
#!/bin/sh

echo "I'm the configure hook"
27 changes: 27 additions & 0 deletions integration_tests/snaps/hooks/snapcraft.yaml
@@ -0,0 +1,27 @@
name: hooks-test
version: '0.1'
summary: Hooks test
description: |
This snap simply includes a few hooks. It does so via two methods:

1) Simply distributing hook scripts in snap/hooks.
2) Installing hook scripts into snap/hooks from parts.

Both of these methods should work, and the first should overwrite the second
if the same hook is supplied via both methods (as it is here).

grade: devel
confinement: strict

parts:
another-hook:
plugin: dump
source: another-hook/
organize:
another-hook: snap/hooks/another-hook

configure-hook:
plugin: dump
source: configure-again/
organize:
configure: snap/hooks/configure
58 changes: 58 additions & 0 deletions integration_tests/test_hooks.py
@@ -0,0 +1,58 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2015 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 os

from testtools.matchers import (
Contains,
FileContains,
FileExists,
Not
)

import integration_tests


class HookTestCase(integration_tests.TestCase):

def test_hooks(self):
project_dir = 'hooks'
self.run_snapcraft('prime', project_dir)

primedir = os.path.join(project_dir, 'prime')

for hook in ('configure', 'another-hook'):
# Assert that the hooks as supplied to snapcraft was copied into
# the snap.
self.assertThat(
os.path.join(primedir, 'snap', 'hooks', hook), FileExists())

# Assert that the real hooks were generated as well (they're just
# wrappers that call the one given to snapcraft).
self.assertThat(
os.path.join(primedir, 'meta', 'hooks', hook), FileExists())

# Assert that the wrapper execs the correct thing
self.assertThat(
os.path.join(primedir, 'meta', 'hooks', 'another-hook'),
FileContains(matcher=Contains('exec "$SNAP/snap/hooks/{}"'.format(
hook))))

# Assert that the configure hook doesn't have a wrapper
self.assertThat(
os.path.join(primedir, 'meta', 'hooks', 'configure'),
Not(FileContains(matcher=Contains('exec'.format(
hook)))))
14 changes: 14 additions & 0 deletions schema/snapcraft.yaml
Expand Up @@ -133,6 +133,20 @@ properties:
items:
type: string
pattern: "^[a-zA-Z0-9][-_.a-zA-Z0-9]*$"
hooks:
type: object
additionalProperties: false
patternProperties:
"^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$":
type: object
additionalProperties: false
properties:
plugs:
type: array
minitems: 1
uniqueItems: true
items:
type: string
parts:
type: object
minProperties: 1
Expand Down
68 changes: 65 additions & 3 deletions snapcraft/internal/meta.py
@@ -1,6 +1,6 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright (C) 2016 Canonical Ltd
# Copyright (C) 2016, 2017 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
Expand All @@ -14,12 +14,14 @@
# 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 contextlib
import os
import configparser
import logging
import re
import shlex
import shutil
import stat
import subprocess
import tempfile

Expand Down Expand Up @@ -49,6 +51,7 @@
'confinement',
'epoch',
'grade',
'hooks',
]


Expand All @@ -58,15 +61,19 @@ class CommandError(Exception):

def create_snap_packaging(config_data, snap_dir, parts_dir):
"""Create snap.yaml and related assets in meta.
Create the meta directory and provision it with snap.yaml
in the snap dir using information from config_data.

Create the meta directory and provision it with snap.yaml in the snap dir
using information from config_data. Also copy in the local 'snap'
directory, and generate wrappers for hooks coming from parts.

:param dict config_data: project values defined in snapcraft.yaml.
:return: meta_dir.
"""
packaging = _SnapPackaging(config_data, snap_dir, parts_dir)
packaging.write_snap_yaml()
packaging.setup_assets()
packaging.generate_hook_wrappers()
packaging.write_snap_directory()

return packaging.meta_dir

Expand Down Expand Up @@ -116,6 +123,61 @@ def setup_assets(self):
file_utils.link_or_copy(
'gadget.yaml', os.path.join(self.meta_dir, 'gadget.yaml'))

def write_snap_directory(self):
# First migrate the snap directory. It will overwrite any conflicting
# files.
for root, directories, files in os.walk('snap'):
for directory in directories:
source = os.path.join(root, directory)
destination = os.path.join(self._snap_dir, source)
file_utils.create_similar_directory(source, destination)

for file_path in files:
source = os.path.join(root, file_path)
destination = os.path.join(self._snap_dir, source)
with contextlib.suppress(FileNotFoundError):
os.remove(destination)
file_utils.link_or_copy(source, destination)

# Now copy the hooks contained within the snap directory directly into
# meta (they don't get wrappers like the ones that come from parts).
snap_hooks_dir = os.path.join('snap', 'hooks')
hooks_dir = os.path.join(self._snap_dir, 'meta', 'hooks')
if os.path.isdir(snap_hooks_dir):
os.makedirs(hooks_dir, exist_ok=True)
for hook_name in os.listdir(snap_hooks_dir):
source = os.path.join(snap_hooks_dir, hook_name)
destination = os.path.join(hooks_dir, hook_name)

# First, verify that the hook is actually executable
if not os.stat(source).st_mode & stat.S_IEXEC:
raise CommandError('hook {!r} is not executable'.format(
hook_name))

with contextlib.suppress(FileNotFoundError):
os.remove(destination)

file_utils.link_or_copy(source, destination)

def generate_hook_wrappers(self):
snap_hooks_dir = os.path.join(self._snap_dir, 'snap', 'hooks')
hooks_dir = os.path.join(self._snap_dir, 'meta', 'hooks')
if os.path.isdir(snap_hooks_dir):
os.makedirs(hooks_dir, exist_ok=True)
for hook_name in os.listdir(snap_hooks_dir):
file_path = os.path.join(snap_hooks_dir, hook_name)
# First, verify that the hook is actually executable
if not os.stat(file_path).st_mode & stat.S_IEXEC:
raise CommandError('hook {!r} is not executable'.format(
hook_name))

hook_exec = os.path.join('$SNAP', 'snap', 'hooks', hook_name)
hook_path = os.path.join(hooks_dir, hook_name)
with contextlib.suppress(FileNotFoundError):
os.remove(hook_path)

self._write_wrap_exe(hook_exec, hook_path)

def _setup_from_setup(self):
setup_dir = 'setup'
if not os.path.exists(setup_dir):
Expand Down
2 changes: 1 addition & 1 deletion snapcraft/tests/__init__.py
Expand Up @@ -99,7 +99,7 @@ def setUp(self):
self.addCleanup(patcher.stop)

# These are what we expect by default
self.snap_dir = os.path.join(os.getcwd(), 'prime')
self.prime_dir = os.path.join(os.getcwd(), 'prime')
self.stage_dir = os.path.join(os.getcwd(), 'stage')
self.parts_dir = os.path.join(os.getcwd(), 'parts')
self.local_plugins_dir = os.path.join(self.parts_dir, 'plugins')
Expand Down
12 changes: 6 additions & 6 deletions snapcraft/tests/commands/test_clean.py
Expand Up @@ -83,7 +83,7 @@ def test_clean_all(self):

self.assertFalse(os.path.exists(self.parts_dir))
self.assertFalse(os.path.exists(self.stage_dir))
self.assertFalse(os.path.exists(self.snap_dir))
self.assertFalse(os.path.exists(self.prime_dir))

def test_local_plugin_not_removed(self):
self.make_snapcraft_yaml(n=3)
Expand All @@ -95,7 +95,7 @@ def test_local_plugin_not_removed(self):
main(['clean'])

self.assertFalse(os.path.exists(self.stage_dir))
self.assertFalse(os.path.exists(self.snap_dir))
self.assertFalse(os.path.exists(self.prime_dir))
self.assertTrue(os.path.exists(self.parts_dir))
self.assertTrue(os.path.isfile(local_plugin))

Expand All @@ -106,7 +106,7 @@ def test_clean_all_when_all_parts_specified(self):

self.assertFalse(os.path.exists(self.parts_dir))
self.assertFalse(os.path.exists(self.stage_dir))
self.assertFalse(os.path.exists(self.snap_dir))
self.assertFalse(os.path.exists(self.prime_dir))

def test_partial_clean(self):
parts = self.make_snapcraft_yaml(n=3)
Expand All @@ -123,7 +123,7 @@ def test_partial_clean(self):

self.assertTrue(os.path.exists(self.parts_dir))
self.assertTrue(os.path.exists(self.stage_dir))
self.assertTrue(os.path.exists(self.snap_dir))
self.assertTrue(os.path.exists(self.prime_dir))

# Now clean it the rest of the way
main(['clean', 'clean1'])
Expand All @@ -135,7 +135,7 @@ def test_partial_clean(self):

self.assertFalse(os.path.exists(self.parts_dir))
self.assertFalse(os.path.exists(self.stage_dir))
self.assertFalse(os.path.exists(self.snap_dir))
self.assertFalse(os.path.exists(self.prime_dir))

def test_everything_is_clean(self):
"""Don't crash if everything is already clean."""
Expand Down Expand Up @@ -237,7 +237,7 @@ def setUp(self):
'w').close()

os.makedirs(self.stage_dir)
os.makedirs(self.snap_dir)
os.makedirs(self.prime_dir)

def assert_clean(self, parts):
for part in parts:
Expand Down
4 changes: 2 additions & 2 deletions snapcraft/tests/commands/test_cleanbuild.py
Expand Up @@ -58,7 +58,7 @@ def test_cleanbuild(self, mock_installed, mock_call):
dirs = [
os.path.join(self.parts_dir, 'part1', 'src'),
self.stage_dir,
self.snap_dir,
self.prime_dir,
os.path.join(self.parts_dir, 'plugins'),
]
files_tar = [
Expand All @@ -67,7 +67,7 @@ def test_cleanbuild(self, mock_installed, mock_call):
]
files_no_tar = [
os.path.join(self.stage_dir, 'binary'),
os.path.join(self.snap_dir, 'binary'),
os.path.join(self.prime_dir, 'binary'),
'snap-test.snap',
'snap-test_1.0_source.tar.bz2',
]
Expand Down
8 changes: 4 additions & 4 deletions snapcraft/tests/commands/test_prime.py
Expand Up @@ -75,11 +75,11 @@ def test_prime_defaults(self):

main(['prime'])

self.assertTrue(os.path.exists(self.snap_dir),
self.assertTrue(os.path.exists(self.prime_dir),
'Expected a prime directory')
self.assertTrue(
os.path.exists(
os.path.join(self.snap_dir, 'meta', 'snap.yaml')),
os.path.join(self.prime_dir, 'meta', 'snap.yaml')),
'Expected a snap.yaml')
self.assertTrue(os.path.exists(self.stage_dir),
'Expected a stage directory')
Expand All @@ -99,9 +99,9 @@ def test_prime_one_part_only_from_3(self):

self.assertFalse(
os.path.exists(
os.path.join(self.snap_dir, 'meta', 'snap.yaml')),
os.path.join(self.prime_dir, 'meta', 'snap.yaml')),
'There should not be a snap.yaml')
self.assertTrue(os.path.exists(self.snap_dir),
self.assertTrue(os.path.exists(self.prime_dir),
'Expected a prime directory')
self.assertTrue(os.path.exists(self.stage_dir),
'Expected a stage directory')
Expand Down