Local V2 plugins

In response to #23875, I’m working on a V2 plugin for Ruby. I took the V2 Rust plugin as a reference. Unfortunately snapcraft fails to load my local plugin. I tried different combinations of file names like my_ruby.py, x_my_ruby.py, x-my-ruby.py and referencing it from the snapcraft.yaml by my-ruby, my_ruby, and x-my-ruby. No luck.

There don’t seem to be any examples of local V2 plugins in the snapcraft repo on GitHub.

How to do this?

Here’s an example for you, https://github.com/MrCarroll/joplin-desktop-snap/blob/master/snap/plugins/joplin.py

The path is mandatory (root/snap/plugins/name.py)

All the get* functions are mandatory too, if you miss one Snapcraft will not work properly and the error messages are misleading, so be careful not to trip up there.

1 Like

Thanks for the help. I don’t see what you’re doing differently.

I got this layout:

.
├── Gemfile
├── Gemfile.lock
└── snap
    ├── plugins
    │   ├── myruby.py
    │   └── __pycache__
    │       └── myruby.cpython-36.pyc
    └── snapcraft.yaml

3 directories, 5 files

And this is my snapcraft.yaml file:

name: test-my-ruby-plugin
base: core20
version: '0.1.0'
summary: Test my ruby plugin
description: |
  Foo bar

grade: devel
confinement: devmode # use 'strict' once you have the right plugs and slots

parts:
  my-snap-test:
    plugin: myruby
    #use-bundler: true
    source: .

apps:
  my-snap-test:
    command: bin/hello-world

And my plugin file:

"""
This ruby plugin is useful for building ruby based parts.
"""

from textwrap import dedent
from typing import Any, Dict, List, Set

from snapcraft.plugins.v2 import PluginV2


class MyrubyPlugin(PluginV2):
    @classmethod
    def get_schema(cls) -> Dict[str, Any]:
        return {
            "$schema": "http://json-schema.org/draft-04/schema#",
            "type": "object",
            "additionalProperties": False,
            "properties": {
                "ruby-version": {
                    "type": "string",
                    "default": "3.0.1",
                    "pattern": r"^\d+\.\d+(\.\d+)?$",
                },
                "use-bundler": {
                    "type": "boolean",
                    "default": False,
                },
            },
            "required": ["source"],
        }

    def __init__(self, *, part_name: str, options) -> None:
        super().__init__()
        self._ruby_version = options.ruby_version
        feature_pattern = re.compile(r"^(\d+\.\d+)\..*$")
        self._feature_version = feature_pattern.sub(r"\1", self._ruby_version)

    def get_build_snaps(self) -> Set[str]:
        return set()

    def get_build_packages(self) -> Set[str]:
        return {"gcc", "make", "zlib1g-dev", "libssl-dev", "libreadline-dev"}

    def get_build_environment(self) -> Dict[str, str]:
        #return {"PATH": "${HOME}/.cargo/bin:${PATH}"}
        return {}

    def _get_download_command(self) -> str:
        url = "https://cache.ruby-lang.org/pub/ruby/{}/ruby-{}.tar.gz".format(
            self._feature_version, self._ruby_version
        )

        # if ! [ -f "${{HOME}}/ruby.tar.gz" ]; then
        #     curl --proto '=https' --tlsv1.2 -sSf {url} > ${{HOME}}/ruby.tar.gz
        # fi

        return dedent(
            f"""\
        pwd
        """
        )

    def _get_install_command(self) -> str:
        #cmd = ["make", '-j"${SNAPCRAFT_PARALLEL_BUILD_COUNT}"']
        # cmd = [
        #     'echo "parallel_build_count: ${SNAPCRAFT_PARALLEL_BUILD_COUNT}"',
        #     'pwd',
        # ]

        # if self.options.use_bundler:
        #     logger.info("Using bundler")
        #     # cmd.extend(
        #         # ["--features", "'{}'".format(" ".join(self.options.rust_features))]
        #     # )

        # return " ".join(cmd)

        return 'echo "my install command"'

    def get_build_commands(self) -> List[str]:
        return [self._get_command_command(), self._get_install_command()]

I just reinstalled snapcraft and multipass completely (multipass couldn’t determine the IP of the instance anymore). I’m still getting this error:

[...]
Setting up netplan.io (0.101-0ubuntu3~20.04.2) ...
Setting up snapd (2.48.3+20.04) ...
Installing new version of config file /etc/apparmor.d/usr.lib.snapd.snap-confine.real ...
snapd.failure.service is a disabled or a static unit, not starting it.
snapd.snap-repair.service is a disabled or a static unit, not starting it.
Setting up systemd-sysv (245.4-4ubuntu3.6) ...
Setting up cloud-init (20.4.1-0ubuntu1~20.04.1) ...
Installing new version of config file /etc/cloud/cloud.cfg ...
Processing triggers for mime-support (3.64ubuntu1) ...
Processing triggers for libc-bin (2.31-0ubuntu9.2) ...
Processing triggers for dbus (1.12.16-2ubuntu2.1) ...
Processing triggers for linux-image-5.4.0-71-generic (5.4.0-71.79) ...
/etc/kernel/postinst.d/initramfs-tools:
update-initramfs: Generating /boot/initrd.img-5.4.0-71-generic
W: Couldn't identify type of root file system for fsck hook
/etc/kernel/postinst.d/zz-update-grub:
Sourcing file `/etc/default/grub'
Sourcing file `/etc/default/grub.d/40-force-partuuid.cfg'
Sourcing file `/etc/default/grub.d/init-select.cfg'
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-5.4.0-71-generic
Found initrd image: /boot/initrd.img-5.4.0-71-generic
Found linux image: /boot/vmlinuz-5.4.0-53-generic
Found initrd image: /boot/initrd.img-5.4.0-53-generic
done
Processing triggers for ca-certificates (20210119~20.04.1) ...
Updating certificates in /etc/ssl/certs...
0 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.
Processing triggers for initramfs-tools (0.136ubuntu6.4) ...
update-initramfs: Generating /boot/initrd.img-5.4.0-71-generic
W: Couldn't identify type of root file system for fsck hook
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following NEW packages will be installed:
  apt-transport-https
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
Need to get 1,704 B of archives.
After this operation, 161 kB of additional disk space will be used.
Get:1 http://archive.ubuntu.com/ubuntu focal-updates/universe amd64 apt-transport-https all 2.0.5 [1,704 B]
Fetched 1,704 B in 0s (14.8 kB/s)
debconf: delaying package configuration, since apt-utils is not installed
Selecting previously unselected package apt-transport-https.
(Reading database ... 20219 files and directories currently installed.)
Preparing to unpack .../apt-transport-https_2.0.5_all.deb ...
Unpacking apt-transport-https (2.0.5) ...
Setting up apt-transport-https (2.0.5) ...
2021-04-15T15:59:16+02:00 INFO Waiting for automatic snapd restart...
snapd 2.49.2 from Canonical✓ installed
core20 20210319 from Canonical✓ installed
core18 20210309 from Canonical✓ installed
"core18" switched to the "latest/stable" channel

snapcraft 4.6.2 from Canonical✓ installed
"snapcraft" switched to the "latest/stable/ubuntu-20.04" channel

Loaded local plugin for myruby
Failed to load plugin: unknown plugin: 'myruby'

It’s so confusing. Why does it first say (in green) Loaded local plugin for myruby and then (in red) Failed to load plugin: unknown plugin: 'myruby'?

Also, why does it install core18 if I’m using core20?

Try renaming your plugin class from MyrubyPlugin to PluginImpl, like in @James-Carroll’s example. This is a requirement for local V2 plugins:

Thank you. Would be great if the docs would include details about V2 plugins.

Is there a way for a V2 plugin to set runtime environment variables? I know about those for the build step, but what about later? Ruby has to find its standard library, whos path is kinda fix during compile time. The standard library gets moved from parts/X/install/lib/ruby/... to stage/lib/ruby and eventually to /prime/lib/ruby (I assume), and Ruby has to know where those are. With the env var RUBYLIB Ruby can be told where to look for those.

There’s no way for plugins to directly influence that. From your snapcraft.yaml file, you can set it using the environment: section though.

I’m not that familiar with Ruby, but in the case of Python it sets it’s default library search path based on argv[0] of the interpreter. Does Ruby do anything similar to that?

Ruby doesn’t look for libraries relative to the executable being run, as far as I know. The load paths (search directories) are set at compile time (depending on ./configure --prefix=/path/), and at runtime using the $RUBYLIB environment variable and the option -I.

I made the plugin work. In the end I used a wrapper script that sets $RUBYLIB (and $GEM_PATH for gems) and then execs Ruby.

I imagine if the snap is jailed, then the paths set by ./configure --prefix=/ would actually be correct and no wrapper script is needed. But I haven’t gotten my test snap to install in jail mode, even though I set confinement: strict and grade: stable. Any ideas?

I’m planning to open a PR soon. Is there a proper place to put plugin support files (the wrapper script)? Or should I have the build commands create this wrapper script instead?

Any files your plugin puts in its install directory should end up installed to the system under $SNAP. If you can’t do environment variable expansion and need an absolute path, you can assume this is /snap/$snap_name/current.

If you think your plugin is generally useful, you could contribute it to Snapcraft (probably easiest as a pull request against the project).

I was referring to the wrapper script that I’d have to include in my PR. Where should I put it? Or should I change the plugin to just issue a shell command that will create the wrapper script?

Thanks for the info. /snap/$snap_name/current should come in handy.

Yeah the plugin is useful because it runs on core20, which the v1 ruby plugin doesn’t.

I might be wrong, but I believe there are distros that don’t use the /snap directory, right?

That’s true, but if you’re writing a strict confined snap it’s irrelevant. The snap sandbox involves running with a private mount namespace. In that environment, the snap’s data will be available under /snap/$snap_name/current, no matter where the data is mounted on the host system’s primary mount namespace.

1 Like

Good news: I learned about Ruby’s ./configure --enable-load-relative which makes Ruby find its stdlib just like Python does. This eliminates the need for a wrapper script.

Furthermore, the plugin now uses ruby-install to compile Ruby. This allows one to specify a Ruby minor version and it’ll automatically determine the most recent patch version of it, and theoretically also allows one to choose any other Ruby flavor (JRuby, TruffleRuby, …).

I guess I could rename the plugin to ruby-install now and file a PR.

3 Likes

I’d suggest keeping the name as ruby to indicate that it is an obvious upgrade path from the v1 plugin. Ideally this would embody the best way to create a snap of a Ruby application, rather than having multiple options and leaving it up to the user to pick one.