Skip to content

Commit

Permalink
feat(craft-application): support remote build
Browse files Browse the repository at this point in the history
This also support --platform and --build-for.
  • Loading branch information
syu-w committed Mar 18, 2024
1 parent 0788b18 commit 1bde962
Show file tree
Hide file tree
Showing 14 changed files with 753 additions and 41 deletions.
31 changes: 30 additions & 1 deletion .github/workflows/spread-scheduled.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ jobs:
run: |
spread google:ubuntu-22.04-64:tests/spread/plugins/${{ matrix.type }}/kernel
remote-build-core24:
runs-on: self-hosted
needs: [snap-build]
strategy:
fail-fast: false
matrix:
system:
- ubuntu-24.04-64
steps:
- name: Cleanup job workspace
run: |
rm -rf "${{ github.workspace }}"
mkdir "${{ github.workspace }}"
- name: Checkout snapcraft
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Download snap artifact
uses: actions/download-artifact@v4
with:
name: snap
path: tests
- name: remote-build test
env:
LAUNCHPAD_TOKEN: "${{ secrets.LAUNCHPAD_TOKEN }}"
run: |
spread google:${{ matrix.system }}:tests/spread/core24/remote-build
remote-build:
runs-on: self-hosted
needs: [snap-build]
Expand Down Expand Up @@ -79,4 +108,4 @@ jobs:
env:
LAUNCHPAD_TOKEN: "${{ secrets.LAUNCHPAD_TOKEN }}"
run: |
spread google:${{ matrix.system }}:tests/spread/general/remote-build
spread google:${{ matrix.system }}:tests/spread/general/remote-build
2 changes: 1 addition & 1 deletion requirements-devel.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ click==8.1.7
codespell==2.2.6
colorama==0.4.6
coverage==7.4.0
craft-application @ git+https://github.com/canonical/craft-application@main
craft-application @ git+https://github.com/canonical/craft-application@CRAFT-2562-build-for-platform
craft-archives==1.1.3
craft-cli==2.5.1
craft-grammar==1.1.2
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ cffi==1.16.0
chardet==5.2.0
charset-normalizer==3.3.2
click==8.1.7
craft-application @ git+https://github.com/canonical/craft-application@main
craft-application @ git+https://github.com/canonical/craft-application@CRAFT-2562-build-for-platform
craft-archives==1.1.3
craft-cli==2.5.1
craft-grammar==1.1.2
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def recursive_data_files(directory, install_directory):
"attrs",
"catkin-pkg; sys_platform == 'linux'",
"click",
"craft-application @ git+https://github.com/canonical/craft-application@main",
"craft-application @ git+https://github.com/canonical/craft-application@CRAFT-2562-build-for-platform",
"craft-archives",
"craft-cli",
"craft-grammar",
Expand Down
2 changes: 1 addition & 1 deletion snapcraft/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def create_app() -> Snapcraft:
craft_app_commands.lifecycle.PrimeCommand,
craft_app_commands.lifecycle.PackCommand,
commands.SnapCommand, # Hidden (legacy compatibility)
unimplemented.RemoteBuild,
commands.RemoteBuildCommand,
unimplemented.Plugins,
unimplemented.ListPlugins,
unimplemented.Try,
Expand Down
2 changes: 2 additions & 0 deletions snapcraft/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
from . import core22, legacy
from .extensions import ExpandExtensions, ListExtensions
from .lifecycle import SnapCommand
from .remote import RemoteBuildCommand

__all__ = [
"core22",
"legacy",
"SnapCommand",
"RemoteBuildCommand",
"ExpandExtensions",
"ListExtensions",
]
270 changes: 270 additions & 0 deletions snapcraft/commands/remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2024 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/>.

"""Snapcraft remote build command that using craft-application."""

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

from craft_application.commands import ExtensibleCommand
from craft_application.launchpad.models import Build, BuildState
from craft_application.remote.utils import get_build_id
from craft_cli import emit
from overrides import overrides

from snapcraft import models
from snapcraft.const import SUPPORTED_ARCHS
from snapcraft.utils import confirm_with_user

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


class RemoteBuildCommand(ExtensibleCommand):
"""Command passthrough for the remote-build command."""

always_load_project = True
name = "remote-build"
help_msg = "Build a snap remotely on Launchpad."
overview = textwrap.dedent(
"""
Command remote-build sends the current project to be built
remotely. After the build is complete, packages for each
architecture are retrieved and will be available in the
local filesystem.
If not specified in the snapcraft.yaml file, the list of
architectures to build can be set using the --platforms option.
If both are specified, an error will occur.
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 of snapcraft is
interrupted or times out.
"""
)

@overrides
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--platform",
type=str,
metavar="name",
default=os.getenv("CRAFT_PLATFORM"),
help="Set platform to build for",
)
group.add_argument(
"--build-for",
type=str,
metavar="arch",
default=os.getenv("CRAFT_BUILD_FOR"),
help="Set architecture to build for",
)

parser.add_argument(
"--recover", action="store_true", help="recover an interrupted build"
)
parser.add_argument(
"--status", action="store_true", help="display remote build status"
)
parser.add_argument(
"--build-id", metavar="build-id", help="specific build id to retrieve"
)
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.",
)

# pylint: disable=too-many-branches, too-many-statements
def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None:
"""Run the remote-build command.
:param parsed_args: Snapcraft's argument namespace.
:raises AcceptPublicUploadError: If the user does not agree to upload data.
"""
if os.getenv("SUDO_USER") and os.geteuid() == 0:
emit.progress(
"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 confirm_with_user(
_CONFIRMATION_PROMPT, default=False
):
emit.progress(
"Remote build needs explicit acknowledgement that data sent to build servers is "
"public.\n"
"In non-interactive runs, please use the option "
"`--launchpad-accept-public-upload`.",
permanent=True,
)
return 77

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

emit.trace(f"Project directory: {project_dir}")

build_planner = self._app.BuildPlannerClass.unmarshal(project.marshal())
full_build_plan = build_planner.get_build_plan()

plan_architectures: set[str] = set()

# platform and build-for are mutually exclusive
if parsed_args.platform:
for build_info in full_build_plan:
if build_info.platform == parsed_args.platform:
plan_architectures.add(build_info.build_for)

Check warning on line 166 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L164-L166

Added lines #L164 - L166 were not covered by tests

if not plan_architectures:
emit.progress(

Check warning on line 169 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L168-L169

Added lines #L168 - L169 were not covered by tests
f"platform {parsed_args.platform} is not present in the build plan."
)
return 1

Check warning on line 172 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L172

Added line #L172 was not covered by tests

if parsed_args.build_for:
if parsed_args.build_for in SUPPORTED_ARCHS:
plan_architectures.add(parsed_args.build_for)

Check warning on line 176 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L175-L176

Added lines #L175 - L176 were not covered by tests
else:
emit.progress(f"build-for {parsed_args.build_for} is not supported.")
return 1

Check warning on line 179 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L178-L179

Added lines #L178 - L179 were not covered by tests

architectures: list[str] | None = list(plan_architectures)

if not architectures:
architectures = None

if parsed_args.launchpad_timeout:
emit.debug(f"Setting timeout to {parsed_args.launchpad_timeout} seconds")
builder.set_timeout(parsed_args.launchpad_timeout)

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, architectures=architectures)

try:
returncode = self._monitor_and_complete(build_id, builds)
except KeyboardInterrupt:
if confirm_with_user("Cancel builds?", default=True):
emit.progress("Cancelling builds.")
builder.cancel_builds()
emit.progress("Cleaning up")
builder.cleanup()
returncode = 0

Check warning on line 208 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L203-L208

Added lines #L203 - L208 were not covered by tests
else:
emit.progress("Cleaning up")
builder.cleanup()
return returncode

def _monitor_and_complete(
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)

Check warning on line 231 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L221-L231

Added lines #L221 - L231 were not covered by tests
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))

Check warning on line 243 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L233-L243

Added lines #L233 - L243 were not covered by tests
except TimeoutError:
if build_id:
resume_command = (

Check warning on line 246 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L245-L246

Added lines #L245 - L246 were not covered by tests
f"{self._app.name} remote-build --recover --build-id={build_id}"
)
else:
resume_command = f"{self._app.name} remote-build --recover"
emit.message(

Check warning on line 251 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L250-L251

Added lines #L250 - L251 were not covered by tests
f"Timed out waiting for build.\nTo resume, run {resume_command!r}"
)
return 75 # Temporary failure

Check warning on line 254 in snapcraft/commands/remote.py

View check run for this annotation

Codecov / codecov/patch

snapcraft/commands/remote.py#L254

Added line #L254 was not covered by tests

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

emit.progress("Fetching build artifacts...")
artifacts = builder.fetch_artifacts(Path.cwd())

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
6 changes: 0 additions & 6 deletions snapcraft/commands/unimplemented.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,3 @@ class EditValidationSets(
UnimplementedMixin, commands.core22.StoreEditValidationSetsCommand
): # noqa: D101 (missing docstring)
pass


class RemoteBuild(
UnimplementedMixin, commands.core22.RemoteBuildCommand
): # noqa: D101 (missing docstring)
pass
2 changes: 2 additions & 0 deletions snapcraft/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
from snapcraft.services.lifecycle import Lifecycle
from snapcraft.services.package import Package
from snapcraft.services.provider import Provider
from snapcraft.services.remotebuild import RemoteBuild
from snapcraft.services.service_factory import SnapcraftServiceFactory

__all__ = [
"Lifecycle",
"Package",
"Provider",
"RemoteBuild",
"SnapcraftServiceFactory",
]
Loading

0 comments on commit 1bde962

Please sign in to comment.