Skip to content

Commit

Permalink
feat(rust_plugin): use rustup snap and more options (canonical#626)
Browse files Browse the repository at this point in the history
Adds a few more options to the existing Rust plugin and switch
to the rustup snap instead of the ad-hoc installation method.

The `rust_inherit_ldflags` option is not enabled by default to
preserve compatibility with existing projects.
  • Loading branch information
liushuyu authored Jan 30, 2024
1 parent 5529031 commit 41d9e95
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 42 deletions.
128 changes: 102 additions & 26 deletions craft_parts/plugins/rust_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,20 @@
"""The craft Rust plugin."""

import logging
import os
import re
import subprocess
from textwrap import dedent
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, cast

from overrides import override
from pydantic import conlist
from pydantic import validator as pydantic_validator

from . import validator
from .base import Plugin, PluginModel, extract_plugin_properties
from .properties import PluginProperties

GET_RUSTUP_COMMAND_TEMPLATE = (
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | "
"sh -s -- -y --no-modify-path --profile=minimal --default-toolchain {channel}"
)

logger = logging.getLogger(__name__)

# A workaround for mypy false positives
Expand All @@ -53,9 +51,29 @@ class RustPluginProperties(PluginProperties, PluginModel):
rust_channel: Optional[str] = None
rust_use_global_lto: bool = False
rust_no_default_features: bool = False
rust_ignore_toolchain_file: bool = False
rust_cargo_parameters: List[str] = []
rust_inherit_ldflags: bool = False
source: str
after: Optional[UniqueStrList] = None

@pydantic_validator("rust_channel")
@classmethod
def validate_rust_channel(cls, value: Optional[str]) -> Optional[str]:
"""Validate the rust-channel property.
:param value: The value to validate.
:return: The validated value.
"""
if value is None or value == "none":
return value
if re.match(r"^(stable|beta|nightly)(-[\w-]+)?$", value):
return value
if re.match(r"^\d+\.\d+(\.\d+)?(-[\w-]+)?$", value):
return value
raise ValueError(f"Invalid rust-channel: {value}")

@classmethod
@override
def unmarshal(cls, data: Dict[str, Any]) -> "RustPluginProperties":
Expand Down Expand Up @@ -125,9 +143,19 @@ class RustPlugin(Plugin):
If you don't want this plugin to install Rust toolchain for you,
you can put "none" for this option.
- rust-ignore-toolchain-file
(boolean, default False)
Whether to ignore the rust-toolchain.toml file.
The upstream project can use this file to specify which Rust
toolchain to use and which component to install.
If you don't want to follow the upstream project's specifications,
you can put true for this option to ignore the toolchain file.
- rust-features
(list of strings)
Features used to build optional dependencies
Features used to build optional dependencies.
You can specify ["*"] for all features
(including all the optional ones).
- rust-path
(list of strings, default [.])
Expand All @@ -145,6 +173,16 @@ class RustPlugin(Plugin):
reducing the final binary size.
This will forcibly enable LTO for all the crates you specified,
regardless of whether you have LTO enabled in the Cargo.toml file
- rust-cargo-parameters
(list of strings)
Append additional parameters to the Cargo command line.
- rust-inherit-ldflags
(boolean, default False)
Whether to inherit the LDFLAGS from the environment.
This option will add the LDFLAGS from the environment to the
Rust linker directives.
"""

properties_class = RustPluginProperties
Expand All @@ -153,7 +191,11 @@ class RustPlugin(Plugin):
@override
def get_build_snaps(self) -> Set[str]:
"""Return a set of required snaps to install in the build environment."""
return set()
options = cast(RustPluginProperties, self._options)
if not options.rust_channel and self._check_system_rust():
logger.info("Rust is installed on the system, skipping rustup")
return set()
return {"rustup"}

@override
def get_build_packages(self) -> Set[str]:
Expand All @@ -170,37 +212,49 @@ def _check_system_rust(self) -> bool:
else:
return "rustc" in rust_version and "cargo" in cargo_version

def _check_rustup(self) -> bool:
try:
rustup_version = subprocess.check_output(["rustup", "--version"])
return "rustup" in rustup_version.decode("utf-8")
except (subprocess.CalledProcessError, FileNotFoundError):
def _check_toolchain_file(self) -> bool:
"""Return if the rust-toolchain.toml file exists."""
options = cast(RustPluginProperties, self._options)
if options.rust_ignore_toolchain_file:
return False

def _get_setup_rustup(self, channel: str) -> List[str]:
return [GET_RUSTUP_COMMAND_TEMPLATE.format(channel=channel)]
return os.path.exists("rust-toolchain.toml") or os.path.exists("rust-toolchain")

@override
def get_build_environment(self) -> Dict[str, str]:
"""Return a dictionary with the environment to use in the build step."""
return {
variables = {
"PATH": "${HOME}/.cargo/bin:${PATH}",
}
options = cast(RustPluginProperties, self._options)
if options.rust_ignore_toolchain_file:
# add a forced override to ignore the toolchain file
variables["RUSTUP_TOOLCHAIN"] = options.rust_channel or "stable"
return variables

@override
def get_pull_commands(self) -> List[str]:
"""Return a list of commands to run during the pull step."""
options = cast(RustPluginProperties, self._options)
if not options.rust_channel and self._check_system_rust():
logger.info("Rust is installed on the system, skipping rustup")
return []

rust_channel = options.rust_channel or "stable"
if rust_channel == "none":

if rust_channel == "none" or (
not options.rust_channel and self._check_system_rust()
):
logger.info("User does not want to use rustup, skipping")
return []
if not self._check_rustup():
logger.info("Rustup not found, installing it")
return self._get_setup_rustup(rust_channel)
if self._check_toolchain_file():
if options.rust_channel != "none":
logger.warning(
"Specified rust-channel value is overridden by the rust-toolchain.toml file!"
)
logger.info(
"If you don't want this behavior, you can set rust-ignore-toolchain-file to true"
)
logger.info("Using the version defined in rust-toolchain.toml file")
# we need to use a tool managed by rustup to trigger an install
# when the toolchain file is present
# (otherwise rustup won't install the correct version)
return ["cargo --version"]
logger.info("Switch rustup channel to %s", rust_channel)
return [
f"rustup update {rust_channel}",
Expand All @@ -216,8 +270,15 @@ def get_build_commands(self) -> List[str]:
config_cmd: List[str] = []

if options.rust_features:
features_string = " ".join(options.rust_features)
config_cmd.extend(["--features", f"'{features_string}'"])
if "*" in options.rust_features:
if len(options.rust_features) > 1:
raise ValueError(
"Please specify either the wildcard feature or a list of specific features"
)
config_cmd.append("--all-features")
else:
features_string = " ".join(options.rust_features)
config_cmd.extend(["--features", f"'{features_string}'"])

if options.rust_use_global_lto:
logger.info("Adding overrides for LTO support")
Expand All @@ -231,6 +292,21 @@ def get_build_commands(self) -> List[str]:
if options.rust_no_default_features:
config_cmd.append("--no-default-features")

if options.rust_cargo_parameters:
config_cmd.extend(options.rust_cargo_parameters)

if options.rust_inherit_ldflags:
rust_build_cmd.append(
dedent(
"""\
if [ -n "${LDFLAGS}" ]; then
RUSTFLAGS="${RUSTFLAGS:-} -Clink-args=\"${LDFLAGS}\""
export RUSTFLAGS
fi\
"""
)
)

for crate in options.rust_path:
logger.info("Generating build commands for %s", crate)
config_cmd_string = " ".join(config_cmd)
Expand Down
54 changes: 54 additions & 0 deletions docs/base/rust_plugin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ rust-features
Features used to build optional dependencies.
This is equivalent to the ``--features`` option in Cargo.

You can also use ``["*"]`` to select all the feautures available in the project.

.. note::
This option does not override any default features
specified by the project itself.
Expand Down Expand Up @@ -76,6 +78,58 @@ This is equivalent to the ``lto = "fat"`` option in the Cargo.toml file.

If you want better runtime performance, see the :ref:`Performance tuning<perf-tuning>` section below.

rust-ignore-toolchain-file
~~~~~~~~~~~~~~~~~~~~~~~~~~
**Type:** boolean
**Default:** false

Whether to ignore the ``rust-toolchain.toml`` and ``rust-toolchain`` file.
The upstream project can use this file to specify which Rust
toolchain to use and which component to install.
If you don't want to follow the upstream project's specifications,
you can put true for this option to ignore the toolchain file.

rust-cargo-parameters
~~~~~~~~~~~~~~~~~~~~~
**Type:** list of strings
**Default:** []

Append additional parameters to the Cargo command line.

rust-inherit-ldflags
~~~~~~~~~~~~~~~~~~~~~
**Type:** boolean
**Default:** false

Whether to inherit the LDFLAGS from the environment.
This option will add the LDFLAGS from the environment to the
Rust linker directives.

Cargo build system and Rust compiler by default do not respect the `LDFLAGS`
environment variable. This option will cause the craft-parts plugin to
forcibly add the contents inside the `LDFLAGS` to the Rust linker directives
by wrapping and appending the `LDFLAGS` value to `RUSTFLAGS`.

.. note::
You may use this option to tune the Rust binary in a classic Snap to respect
the Snap linkage, so that the binary will not find the libraries in the host
filesystem.

Here is an example on how you might do this on core22:

.. code-block:: yaml
parts:
my-classic-app:
plugin: rust
source: .
rust-inherit-ldflags: true
build-environment:
- LDFLAGS: >
-Wl,-rpath=\$ORIGIN/lib:/snap/core22/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR
-Wl,-dynamic-linker=$(find /snap/core22/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -name 'ld*.so.*' -print | head -n1)
Environment variables
---------------------

Expand Down
27 changes: 11 additions & 16 deletions tests/unit/plugins/test_rust_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from craft_parts.errors import PluginEnvironmentValidationError
from craft_parts.infos import PartInfo, ProjectInfo
from craft_parts.parts import Part
from craft_parts.plugins.rust_plugin import GET_RUSTUP_COMMAND_TEMPLATE, RustPlugin
from craft_parts.plugins.rust_plugin import RustPlugin
from pydantic import ValidationError


Expand All @@ -32,10 +32,13 @@ def part_info(new_dir):
)


def test_get_build_snaps(part_info):
def test_get_build_snaps(fake_process: pytest_subprocess.FakeProcess, part_info):
fake_process.register(["rustc", "--version"], stdout="Not installed")
fake_process.register(["cargo", "--version"], stdout="Not installed")

properties = RustPlugin.properties_class.unmarshal({"source": "."})
plugin = RustPlugin(properties=properties, part_info=part_info)
assert plugin.get_build_snaps() == set()
assert plugin.get_build_snaps() == {"rustup"}


def test_get_build_packages(part_info):
Expand All @@ -62,12 +65,9 @@ def test_get_build_commands_default(part_info):
{"source": ".", "rust-channel": "stable"}
)
plugin = RustPlugin(properties=properties, part_info=part_info)
plugin._check_rustup = lambda: False

commands = plugin.get_build_commands()
assert plugin.get_pull_commands()[0] == GET_RUSTUP_COMMAND_TEMPLATE.format(
channel="stable"
)
assert plugin.get_pull_commands()[0] == "rustup update stable"
assert 'cargo install -f --locked --path "."' in commands[0]


Expand All @@ -79,7 +79,6 @@ def _check_rustup():
{"source": ".", "rust-channel": "none"}
)
plugin = RustPlugin(properties=properties, part_info=part_info)
plugin._check_rustup = _check_rustup

commands = plugin.get_build_commands()
assert len(commands) == 1
Expand All @@ -92,7 +91,6 @@ def test_get_build_commands_use_lto(part_info):
{"source": ".", "rust-use-global-lto": True, "rust-channel": "stable"}
)
plugin = RustPlugin(properties=properties, part_info=part_info)
plugin._check_rustup = lambda: True

commands = plugin.get_build_commands()
assert len(commands) == 1
Expand All @@ -106,7 +104,6 @@ def test_get_build_commands_multiple_crates(part_info):
{"source": ".", "rust-path": ["a", "b", "c"], "rust-channel": "stable"}
)
plugin = RustPlugin(properties=properties, part_info=part_info)
plugin._check_rustup = lambda: True

commands = plugin.get_build_commands()
assert len(commands) == 3
Expand All @@ -121,7 +118,6 @@ def test_get_build_commands_multiple_features(part_info):
{"source": ".", "rust-features": ["ft-a", "ft-b"], "rust-channel": "stable"}
)
plugin = RustPlugin(properties=properties, part_info=part_info)
plugin._check_rustup = lambda: True

commands = plugin.get_build_commands()
assert len(commands) == 1
Expand All @@ -148,10 +144,9 @@ def test_get_build_commands_different_channels(part_info, value):
{"source": ".", "rust-channel": value}
)
plugin = RustPlugin(properties=properties, part_info=part_info)
plugin._check_rustup = lambda: False
commands = plugin.get_build_commands()
assert len(commands) == 1
assert f"--default-toolchain {value}" in plugin.get_pull_commands()[0]
assert value in plugin.get_pull_commands()[0]


@pytest.mark.parametrize(
Expand Down Expand Up @@ -198,7 +193,7 @@ def test_error_on_conflict_config(part_info):
pytest.param(
"You don't have rust installed!",
"You don't have rust installed!",
[GET_RUSTUP_COMMAND_TEMPLATE.format(channel="stable")],
["rustup update stable", "rustup default stable"],
id="not-installed",
),
],
Expand All @@ -217,7 +212,6 @@ def test_get_pull_commands_compat_no_exceptions(
{"source": ".", "after": ["rust-deps"]}
)
plugin = RustPlugin(properties=properties, part_info=part_info)
plugin._check_rustup = lambda: False # type: ignore[method-assign]

commands = plugin.get_build_commands()
assert plugin.get_pull_commands() == pull_commands
Expand Down Expand Up @@ -246,7 +240,8 @@ def callback_fail():
plugin = RustPlugin(properties=properties, part_info=part_info)

assert plugin.get_pull_commands() == [
GET_RUSTUP_COMMAND_TEMPLATE.format(channel="stable")
"rustup update stable",
"rustup default stable",
]


Expand Down

0 comments on commit 41d9e95

Please sign in to comment.