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

feat: add a 'remote-build' command #586

Merged
merged 7 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions craft_application/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
from .init import InitCommand
from .lifecycle import get_lifecycle_command_group, LifecycleCommand
from .other import get_other_command_group
from .remote import RemoteBuild # Not part of the default commands.

__all__ = [
"AppCommand",
"ExtensibleCommand",
"InitCommand",
"RemoteBuild",
"lifecycle",
"LifecycleCommand",
"get_lifecycle_command_group",
Expand Down
206 changes: 206 additions & 0 deletions craft_application/commands/remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Copyright 2024 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 warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, 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/>.
"""Build a project remotely on Launchpad."""

import argparse
import os
import pathlib
import time
from collections.abc import Collection
from typing import Any, cast

from craft_cli import emit
from overrides import override # pyright: ignore[reportUnknownVariableType]

from craft_application import models, util
from craft_application.commands import ExtensibleCommand
from craft_application.launchpad.models import Build, BuildState
from craft_application.remote.utils import get_build_id

OVERVIEW = """\
Command remote-build sends the current project to be built
remotely. After the build is complete, packages for each
tigarmo marked this conversation as resolved.
Show resolved Hide resolved
architecture are retrieved and will be available in the
local filesystem.

Interrupted remote builds can be resumed using the --recover
option, followed by the build number informed when the remote
build was originally dispatched. The current state of the
remote build for each architecture can be checked using the
--status option.

To set a timeout on the remote-build command, use the option
``--launchpad-timeout=<seconds>``. The timeout is local, so
the build on launchpad will continue even if the local instance
is interrupted or times out.
"""

_CONFIRMATION_PROMPT = (
"All data sent to remote builders will be publicly available. "
"Are you sure you want to continue?"
)


class RemoteBuild(ExtensibleCommand):
"""Build a project on Launchpad."""

name = "remote-build"
help_msg = "Build a project remotely on Launchpad."
overview = OVERVIEW
always_load_project = True

@override
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--recover", action="store_true", help="recover an interrupted build"
)
parser.add_argument(
"--launchpad-accept-public-upload",
action="store_true",
help="acknowledge that uploaded code will be publicly available.",
)
parser.add_argument(
"--launchpad-timeout",
type=int,
default=0,
metavar="<seconds>",
help="Time in seconds to wait for launchpad to build.",
)

def _run(
self,
parsed_args: argparse.Namespace,
**_kwargs: Any, # noqa: ANN401 (use of Any)
) -> int | None:
"""Run the remote-build command.

:param parsed_args: parsed argument namespace from craft_cli.

:raises AcceptPublicUploadError: If the user does not agree to upload data.
"""
if os.getenv("SUDO_USER") and os.geteuid() == 0:
emit.progress(
tigarmo marked this conversation as resolved.
Show resolved Hide resolved
"Running with 'sudo' may cause permission errors and is discouraged.",
permanent=True,
)
# Give the user a bit of time to process this before proceeding.
time.sleep(1)

emit.progress(
"remote-build is experimental and is subject to change. Use with caution.",
permanent=True,
)

if (
not parsed_args.launchpad_accept_public_upload
and not util.confirm_with_user(_CONFIRMATION_PROMPT, default=False)
):
emit.message("Cannot proceed without accepting a public upload.")
return 77 # permission denied from sysexits.h
bepri marked this conversation as resolved.
Show resolved Hide resolved

builder = self._services.remote_build
project = cast(models.Project, self._services.project)
config = cast(dict[str, Any], self.config)
project_dir = (
pathlib.Path(config.get("global_args", {}).get("project_dir") or ".")
.expanduser()
.resolve()
)
emit.trace(f"Project directory: {project_dir}")

if parsed_args.launchpad_timeout:
emit.debug(f"Setting timeout to {parsed_args.launchpad_timeout} seconds")
builder.set_timeout(parsed_args.launchpad_timeout)
tigarmo marked this conversation as resolved.
Show resolved Hide resolved

build_id = get_build_id(self._app.name, project.name, project_dir)
if parsed_args.recover:
emit.progress(f"Recovering build {build_id}")
builds = builder.resume_builds(build_id)
else:
emit.progress(
"Starting new build. It may take a while to upload large projects."
)
builds = builder.start_builds(project_dir)

try:
returncode = self._monitor_and_complete(build_id, builds)
except KeyboardInterrupt:
if util.confirm_with_user("Cancel builds?", default=True):
bepri marked this conversation as resolved.
Show resolved Hide resolved
emit.progress("Cancelling builds.")
builder.cancel_builds()
emit.progress("Cleaning up")
builder.cleanup()
returncode = 0
else:
emit.progress("Cleaning up")
builder.cleanup()
return returncode

def _monitor_and_complete( # noqa: PLR0912 (too many branches)
self, build_id: str | None, builds: Collection[Build]
) -> int:
builder = self._services.remote_build
emit.progress("Monitoring build")
try:
for states in builder.monitor_builds():
building: set[str] = set()
succeeded: set[str] = set()
uploading: set[str] = set()
not_building: set[str] = set()
for arch, build_state in states.items():
if build_state.is_running:
building.add(arch)
elif build_state == BuildState.SUCCESS:
succeeded.add(arch)
elif build_state == BuildState.UPLOADING:
uploading.add(arch)
else:
not_building.add(arch)
progress_parts: list[str] = []
if not_building:
progress_parts.append("Stopped: " + ", ".join(sorted(not_building)))
if building:
progress_parts.append("Building: " + ", ".join(sorted(building)))
if uploading:
progress_parts.append("Uploading: " + ", ".join(sorted(uploading)))
if succeeded:
progress_parts.append("Succeeded: " + ", ".join(sorted(succeeded)))
emit.progress("; ".join(progress_parts))
except TimeoutError:
if build_id:
resume_command = (
f"{self._app.name} remote-build --recover --build-id={build_id}"
)
else:
resume_command = f"{self._app.name} remote-build --recover"
emit.message(
f"Timed out waiting for build.\nTo resume, run {resume_command!r}"
)
return 75 # Temporary failure

emit.progress(f"Fetching {len(builds)} build logs...")
logs = builder.fetch_logs(pathlib.Path.cwd())

emit.progress("Fetching build artifacts...")
artifacts = builder.fetch_artifacts(pathlib.Path.cwd())
tigarmo marked this conversation as resolved.
Show resolved Hide resolved

log_names = sorted(path.name for path in logs.values() if path)
artifact_names = sorted(path.name for path in artifacts)

emit.message(
"Build completed.\n"
f"Log files: {', '.join(log_names)}\n"
f"Artifacts: {', '.join(artifact_names)}"
)
return 0
2 changes: 2 additions & 0 deletions craft_application/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""Utilities for craft-application."""

from craft_application.util.callbacks import get_unique_callbacks
from craft_application.util.cli import confirm_with_user
from craft_application.util.docs import render_doc_url
from craft_application.util.logging import setup_loggers
from craft_application.util.paths import get_filename_from_url_path, get_managed_logpath
Expand All @@ -37,6 +38,7 @@

__all__ = [
"get_unique_callbacks",
"confirm_with_user",
"render_doc_url",
"setup_loggers",
"get_filename_from_url_path",
Expand Down
44 changes: 44 additions & 0 deletions craft_application/util/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# This file is part of craft-application.
#
# Copyright 2024 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 warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, 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/>.
"""Utilities related to the command line."""
import sys

from craft_cli import emit


def confirm_with_user(prompt: str, *, default: bool = False) -> bool:
"""Query user for yes/no answer.

If stdin is not a tty, the default value is returned.

If user returns an empty answer, the default value is returned.

:returns: True if answer starts with [yY], False if answer starts with [nN],
otherwise the default.
"""
if not sys.stdin.isatty():
return default

choices = " [Y/n]: " if default else " [y/N]: "

with emit.pause():
reply = input(prompt + choices).lower().strip()

if reply and reply[0] == "y":
return True
if reply and reply[0] == "n":
return False
return default
6 changes: 6 additions & 0 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Changelog
4.6.0 (YYYY-MMM-DD)
-------------------

Commands
========

- Add a ``remote-build`` command. This command is not registered by default,
but is available for application use.

Git
===

Expand Down
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from dataclasses import dataclass
from importlib import metadata
from typing import TYPE_CHECKING, Any
from unittest.mock import Mock

import craft_application
import craft_parts
Expand Down Expand Up @@ -315,6 +316,16 @@ def _get_loader(self, template_dir: pathlib.Path) -> jinja2.BaseLoader:
return FakeInitService


@pytest.fixture
def fake_remote_build_service_class():
class FakeRemoteBuild(services.RemoteBuildService):
@override
def _get_lp_client(self) -> launchpad.Launchpad:
return Mock(spec=launchpad.Launchpad)

return FakeRemoteBuild


@pytest.fixture
def fake_services(
tmp_path,
Expand All @@ -323,10 +334,12 @@ def fake_services(
fake_lifecycle_service_class,
fake_package_service_class,
fake_init_service_class,
fake_remote_build_service_class,
):
services.ServiceFactory.register("package", fake_package_service_class)
services.ServiceFactory.register("lifecycle", fake_lifecycle_service_class)
services.ServiceFactory.register("init", fake_init_service_class)
services.ServiceFactory.register("remote_build", fake_remote_build_service_class)
factory = services.ServiceFactory(app_metadata, project=fake_project)
factory.update_kwargs(
"lifecycle", work_dir=tmp_path, cache_dir=tmp_path / "cache", build_plan=[]
Expand Down
Loading
Loading