# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2025 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""The Ruby plugin."""
import logging
from enum import Enum
from typing import Literal, cast
from typing_extensions import override
from . import validator
from .base import Plugin
from .properties import PluginProperties
logger = logging.getLogger(__name__)
RUBY_PREFIX = "/usr"
# Gem install location (see default search paths in `gem env`)
GEM_PREFIX = "/var/lib/gems/all"
# NOTE: To update ruby-install version, go to https://github.com/postmodern/ruby-install/tags
RUBY_INSTALL_VERSION = "0.10.1"
# NOTE: To update SHA256 checksum, run the following command (with updated version) and copy the output (one line) here:
# curl -L https://github.com/postmodern/ruby-install/archive/refs/tags/v0.10.1.tar.gz -o ruby-install.tar.gz && sha256sum --tag ruby-install.tar.gz
RUBY_INSTALL_CHECKSUM = "SHA256 (ruby-install.tar.gz) = af09889b55865fc2a04e337fb4fe5632e365c0dce871556c22dfee7059c47a33"
[docs]
class RubyFlavor(str, Enum):
"""All Ruby implementations supported by ruby-install."""
ruby = "ruby"
jruby = "jruby"
truffleruby = "truffleruby"
mruby = "mruby"
[docs]
class RubyPluginProperties(PluginProperties, frozen=True):
"""The part properties used by the Ruby plugin."""
plugin: Literal["ruby"] = "ruby"
source: str # pyright: ignore[reportGeneralTypeIssues]
ruby_gems: list[str] = []
ruby_use_bundler: bool = False
# build arguments
ruby_flavor: RubyFlavor | None = None
ruby_version: str | None = None
ruby_use_jemalloc: bool = False
ruby_shared: bool = False
ruby_configure_options: list[str] = []
[docs]
class RubyPluginEnvironmentValidator(validator.PluginEnvironmentValidator):
"""Check the execution environment for the Ruby plugin.
:param part_name: The part whose build environment is being validated.
:param env: A string containing the build step environment setup.
"""
[docs]
@override
def validate_environment(
self, *, part_dependencies: list[str] | None = None
) -> None:
"""Ensure the environment has the dependencies to build Ruby applications.
:param part_dependencies: A list of the parts this part depends on.
"""
options = cast(RubyPluginProperties, self._options)
has_ruby_deps = "ruby-deps" in (part_dependencies or [])
if options.ruby_flavor or options.ruby_version:
if None in (options.ruby_flavor, options.ruby_version):
raise validator.errors.PluginEnvironmentValidationError(
part_name=self._part_name,
reason="ruby-version and ruby-flavor must both be specified",
)
if has_ruby_deps:
raise validator.errors.PluginEnvironmentValidationError(
part_name=self._part_name,
reason="ruby-deps cannot be used "
"when ruby-flavor and ruby-version are also specified",
)
elif not has_ruby_deps:
# Not building Ruby -- everything should already be present
for dependency in ["gem", "ruby"]:
self.validate_dependency(
dependency=dependency,
plugin_name="ruby",
part_dependencies=part_dependencies,
)
[docs]
class RubyPlugin(Plugin):
"""A plugin for Ruby based projects.
The desired Ruby interpreter is compiled using ruby-install.
The ruby plugin uses the following ruby-specific keywords:
- ``ruby-flavor``
(string)
ruby,jruby,truffleruby,mruby
- ``ruby-gems``
(list of str)
Defaults to []
- ``ruby-version``
(str)
Minor version number of the specified interpreter flavor.
e.g. '3.2', meaning the newest release of the 3.2.x series.
- ``ruby-use-jemalloc``
(bool)
Defaults to False
- ``ruby-shared``
(bool)
Defaults to False
- ``ruby-configure-options``
(list of str)
Defaults to []
- ``ruby-use-bundler``
(bool)
Defaults to False
"""
properties_class = RubyPluginProperties
validator_class = RubyPluginEnvironmentValidator
_options: RubyPluginProperties
def _should_build_ruby(self) -> bool:
# Skip if user specified 'after: [ruby-deps]' in yaml
return (
self._options.ruby_flavor is not None
and self._options.ruby_version is not None
) and "ruby-deps" not in self._part_info.part_dependencies
[docs]
def get_build_snaps(self) -> set[str]:
"""Return a set of required snaps to install in the build environment."""
return set()
[docs]
def get_build_packages(self) -> set[str]:
"""Return a set of required packages to install in the build environment."""
packages: set[str] = set()
if self._should_build_ruby():
# curl: for fetching ruby-install itself
# other packages: minimum necessary for mruby flavor
# https://github.com/postmodern/ruby-install/blob/master/share/ruby-install/mruby/dependencies.sh
packages |= {"curl", "build-essential", "bison"}
if self._options.ruby_flavor != RubyFlavor.mruby:
# package dependencies for standard ruby interpreter
# https://github.com/postmodern/ruby-install/blob/master/share/ruby-install/ruby/dependencies.sh
packages |= {
"xz-utils",
"zlib1g-dev",
"libyaml-dev",
"libssl-dev",
"libncurses-dev",
"libffi-dev",
"libreadline-dev",
"libjemalloc-dev",
}
return packages
[docs]
def get_build_environment(self) -> dict[str, str]:
"""Return a dictionary with the environment to use in the build step."""
env = {
# Where to find ruby and gem binaries
# Prioritize staged executables
"PATH": (
f"${{CRAFT_PART_INSTALL}}{RUBY_PREFIX}/bin:"
f"${{CRAFT_PART_INSTALL}}{GEM_PREFIX}/bin:"
f"${{CRAFT_STAGE}}{RUBY_PREFIX}/bin:"
f"${{CRAFT_STAGE}}{GEM_PREFIX}/bin:"
f"${{PATH}}"
),
# Where to find libruby.so
"LD_LIBRARY_PATH": (
f"${{CRAFT_PART_INSTALL}}{RUBY_PREFIX}/lib/${{CRAFT_ARCH_TRIPLET}}:"
f"${{CRAFT_PART_INSTALL}}{RUBY_PREFIX}/lib:"
f"${{CRAFT_STAGE}}{RUBY_PREFIX}/lib/${{CRAFT_ARCH_TRIPLET}}:"
f"${{LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}}"
),
# Where to look for installed gems
"GEM_PATH": f"${{CRAFT_PART_INSTALL}}{GEM_PREFIX}",
# Where to install new gems
"GEM_HOME": f"${{CRAFT_PART_INSTALL}}{GEM_PREFIX}",
# Tell "bundle install" to use same path as "gem install"
"BUNDLE_PATH__SYSTEM": "true",
}
# mruby shouldn't care about gems at all, but the installer
# fails to symlink with the GEM_HOME value above for some reason
if self._options.ruby_flavor == RubyFlavor.mruby:
env["GEM_HOME"] = "${CRAFT_PART_INSTALL}"
return env
def _configure_opts(self) -> list[str]:
configure_opts = [
"--without-baseruby",
"--enable-load-relative",
"--disable-install-doc",
*self._options.ruby_configure_options,
]
if self._options.ruby_shared:
configure_opts.append("--enable-shared")
if self._options.ruby_use_jemalloc:
configure_opts.append("--with-jemalloc")
return configure_opts
[docs]
@override
def get_pull_commands(self) -> list[str]:
"""Return a list of commands to run during the pull step."""
commands: list[str] = []
if self._should_build_ruby():
# NOTE: Download and verify ruby-install tool (to be executed during build phase)
commands.append(
f"curl -L --proto '=https' --tlsv1.2 https://github.com/postmodern/ruby-install/archive/refs/tags/v{RUBY_INSTALL_VERSION}.tar.gz -o ruby-install.tar.gz"
)
commands.append("echo 'Checksum of downloaded file:'")
commands.append("sha256sum --tag ruby-install.tar.gz")
commands.append("echo 'Checksum is correct if it matches:'")
commands.append(f"echo '{RUBY_INSTALL_CHECKSUM}'")
commands.append(
f"echo '{RUBY_INSTALL_CHECKSUM}' | sha256sum --check --strict"
)
return commands
[docs]
@override
def get_build_commands(self) -> list[str]:
"""Return a list of commands to run during the build step."""
configure_opts = " ".join(self._configure_opts())
commands = ["uname -a", "env"]
if self._should_build_ruby():
flavor_str = cast(RubyFlavor, self._options.ruby_flavor).value
# only for mruby: Install the rake gem into our custom GEM_HOME
if self._options.ruby_flavor == RubyFlavor.mruby:
commands.append("gem install --env-shebang --no-document rake")
# NOTE: Use ruby-install to download, compile, and install Ruby
commands.append("tar xfz ruby-install.tar.gz")
commands.append(
f"ruby-install-{RUBY_INSTALL_VERSION}/bin/ruby-install"
f" --src-dir ${{CRAFT_PART_SRC}}"
f" --install-dir ${{CRAFT_PART_INSTALL}}{RUBY_PREFIX}"
f" --no-install-deps --jobs=${{CRAFT_PARALLEL_BUILD_COUNT}}"
f" {flavor_str}-{self._options.ruby_version}"
f" -- {configure_opts}"
)
if self._options.ruby_use_bundler:
# NOTE: Update bundler and avoid conflicts/prompts about replacing bundler
# executables by removing them first.
commands.append(
f"rm -f ${{CRAFT_PART_INSTALL}}{RUBY_PREFIX}/bin/{{bundle,bundler}}"
)
commands.append("gem install --env-shebang --no-document bundler")
commands.append("bundle install --standalone")
# If the source dir itself defines a gem, install it too
# (`bundle install` only installs dependencies)
for gemspec in self._part_info.part_src_dir.glob("*.gemspec"):
commands.append(f"gem build {gemspec} --output {gemspec}.gem")
commands.append(
f"gem install --env-shebang --no-document {gemspec}.gem"
)
if self._options.ruby_gems:
commands.append(
"gem install --env-shebang --no-document {}".format(
" ".join(self._options.ruby_gems)
)
)
return commands