From bf0d215ed51aec91a555dbd4d4d2ebad55e9f7fe Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Fri, 11 Sep 2026 17:49:08 +0200 Subject: [PATCH 01/27] Add Arch Linux PKGBUILD for the full elp stack (daemon + CLI + GUI) Renders packaging/archlinux/PKGBUILD.in against the current checkout and builds it in a sandboxed extra-x86_64-build chroot via scripts/build-arch.sh, with a persistent vcpkg binary cache bind-mounted into the chroot so Qt6/ gRPC/Boost/QEMU only compile from source once. --- BUILD.linux.md | 31 ++++++++- packaging/archlinux/PKGBUILD.in | 115 +++++++++++++++++++++++++++++++ packaging/archlinux/elpd.service | 11 +++ scripts/build-arch.sh | 81 ++++++++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 packaging/archlinux/PKGBUILD.in create mode 100644 packaging/archlinux/elpd.service create mode 100755 scripts/build-arch.sh diff --git a/BUILD.linux.md b/BUILD.linux.md index 3ede6c1192..90f4bc4fc7 100644 --- a/BUILD.linux.md +++ b/BUILD.linux.md @@ -1,6 +1,35 @@ # Build instructions for Linux -## Environment Setup +## Arch Linux + +Build and package the full stack (`elpd` daemon, `elp` CLI, `elp` GUI) as an +installable Arch package: + +``` +sudo pacman -S devtools +./scripts/build-arch.sh +``` + +This renders [`packaging/archlinux/PKGBUILD.in`](packaging/archlinux/PKGBUILD.in) +against the current checkout and builds it in a clean, sandboxed chroot via +devtools' `extra-x86_64-build` (bootstrapped automatically on first run). +Pass `--no-sandbox` to build with a plain `makepkg` in the current +environment instead (you'll need every package listed in the rendered +PKGBUILD's `depends`/`makedepends` installed yourself). See +`./scripts/build-arch.sh --help` for all options. + +The build compiles Qt6, gRPC, Boost, and QEMU from source via vcpkg (see +`vcpkg.json`) — but only the **first** time: the script bind-mounts a +persistent cache directory (`build/vcpkg-binary-cache` by default) into the +chroot and points vcpkg's binary cache at it, so later builds, even in a +freshly recreated chroot, reuse those binaries instead of recompiling them. +Delete that directory to force a full rebuild from scratch. + +Install the resulting package with `pacman -U .pkg.tar.zst`; this +installs `elpd`/`elp`/the GUI, a systemd unit (`systemctl enable --now +elpd`), and desktop/icon/completion files. + +## Ubuntu / apt-based distributions ### Build dependencies diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in new file mode 100644 index 0000000000..f7d5460fbd --- /dev/null +++ b/packaging/archlinux/PKGBUILD.in @@ -0,0 +1,115 @@ +# Maintainer: (local build) generated by scripts/build-arch.sh +# +# Builds the full elp stack (elpd daemon + elp CLI + elp GUI) straight from a +# checkout of this repository. This file is a template: scripts/build-arch.sh +# renders it into a scratch directory, substituting @REPO_SOURCE@ for a +# git+file:// URL pointing at the checkout being built, before invoking +# makepkg / extra-x86_64-build. Don't run `makepkg` on this file directly. + +pkgname=elp +pkgver=0.0.0 +pkgrel=1 +pkgdesc="Electros LaunchPad: create, control and connect to Linux VM instances (daemon, CLI and GUI)" +arch=('x86_64') +url="https://elemento.cloud" +license=('GPL3') +depends=( + 'qt6-base' + 'gcc-libs' + 'glibc' + 'libpng' + 'libxml2' + 'dnsmasq' + 'slang' + 'iproute2' + 'iptables-nft' + 'iputils' + 'xterm' + 'mesa' + 'openssl' + 'libnotify' + 'libayatana-appindicator' + 'gtkmm3' +) +makedepends=( + 'git' + 'cmake' + 'ninja' + 'pkgconf' + 'clang' + 'llvm' + 'lld' + 'bison' + 'autoconf-archive' + 'curl' + 'zip' + 'unzip' + 'python' + 'apparmor' # libapparmor headers + 'systemd-libs' # libsystemd headers + # vcpkg builds Qt6/gRPC/Boost/qemu from source (see ../../vcpkg.json); these + # are the system headers those ports need to build on Linux: + 'libxkbcommon' + 'libxrandr' + 'libxi' + 'libxcursor' + 'libxdamage' + 'libxcomposite' + 'fontconfig' + 'freetype2' + 'wayland' + 'wayland-protocols' + 'dbus' + 'at-spi2-core' + 'harfbuzz' + 'icu' + 'zlib' + 'libjpeg-turbo' + 'glib2' + 'pixman' +) +# Not in the official repos as of writing; install from the AUR first +# (e.g. `yay -S libkeybinder3`) or the build will fail to configure gtkmm's +# tray-icon integration: +# libkeybinder3 (AUR) +options=('!lto') # heavy C++ TUs (Qt/gRPC-generated code); LTO here is not worth the build time +source=("elp::git+@REPO_SOURCE@") +sha256sums=('SKIP') + +pkgver() { + cd "${srcdir}/elp" + git describe --tags --long 2>/dev/null | sed 's/^v//; s/-/./g' || echo 0.0.0 +} + +prepare() { + cd "${srcdir}/elp" + git submodule update --init --recursive +} + +build() { + cd "${srcdir}/elp" + # Persistent, content-addressed vcpkg binary cache: without this, every + # from-scratch chroot rebuilds Qt6/gRPC/Boost/QEMU from source every time. + # scripts/build-arch.sh bind-mounts this same path into the sandbox and + # keeps it around across runs, so only the very first build compiles them. + export VCPKG_BINARY_SOURCES="clear;files,@VCPKG_CACHE_DIR@,readwrite" + cmake -S . -B "${srcdir}/build" \ + -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DMULTIPASS_ENABLE_TESTS=OFF + cmake --build "${srcdir}/build" --parallel +} + +package() { + DESTDIR="${pkgdir}" cmake --install "${srcdir}/build" + + install -Dm644 "${srcdir}/../elpd.service" \ + "${pkgdir}/usr/lib/systemd/system/elpd.service" + install -Dm644 "${srcdir}/elp/data/elp.gui.desktop" \ + "${pkgdir}/usr/share/applications/elp.gui.desktop" + install -Dm644 "${srcdir}/elp/data/elp.gui.png" \ + "${pkgdir}/usr/share/icons/hicolor/512x512/apps/elp.gui.png" + install -Dm644 "${srcdir}/elp/completions/bash/elp" \ + "${pkgdir}/usr/share/bash-completion/completions/elp" +} diff --git a/packaging/archlinux/elpd.service b/packaging/archlinux/elpd.service new file mode 100644 index 0000000000..c307b7ec89 --- /dev/null +++ b/packaging/archlinux/elpd.service @@ -0,0 +1,11 @@ +[Unit] +Description=Electros LaunchPad daemon +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/elpd +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/scripts/build-arch.sh b/scripts/build-arch.sh new file mode 100755 index 0000000000..d298c25293 --- /dev/null +++ b/scripts/build-arch.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Build and package elp (elpd + elp CLI + elp GUI) for Arch Linux. +# +# Renders packaging/archlinux/PKGBUILD.in against this checkout, then builds +# it either in a clean chroot via devtools' extra-x86_64-build (default, +# sandboxed) or with a plain `makepkg` in the current environment. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PKG_TEMPLATE="${ROOT}/packaging/archlinux/PKGBUILD.in" +SERVICE_FILE="${ROOT}/packaging/archlinux/elpd.service" +SCRATCH_DIR="${ARCH_PKG_SCRATCH_DIR:-${ROOT}/build/archlinux-pkg}" +VCPKG_CACHE_DIR="${ARCH_PKG_VCPKG_CACHE:-${ROOT}/build/vcpkg-binary-cache}" +SANDBOXED=1 +EXTRA_ARGS=() + +usage() { + cat <] + +Options: + --no-sandbox Build with a plain 'makepkg' instead of the sandboxed + 'extra-x86_64-build' chroot (requires devtools). + --scratch-dir D Where to render the PKGBUILD (default: ${SCRATCH_DIR}) + --vcpkg-cache D Persistent vcpkg binary cache dir (default: ${VCPKG_CACHE_DIR}) + -h, --help Show this help + +Requires (sandboxed, default): the 'devtools' package (extra-x86_64-build). +Requires (--no-sandbox): 'base-devel' and every dependency listed in +packaging/archlinux/PKGBUILD.in installed on the host. + +vcpkg builds Qt6, gRPC, Boost, and QEMU from source the first time it needs +them. Because 'extra-x86_64-build' rebuilds from a clean chroot copy every +run, that cache would normally be wiped and everything recompiled on every +single build — this script avoids that by bind-mounting a persistent cache +directory (--vcpkg-cache) into the chroot at the same path and pointing +vcpkg's binary cache at it, so only the very first build compiles those +dependencies; later builds (even in a freshly recreated chroot) reuse the +cached binaries. Delete --vcpkg-cache's directory to force a clean rebuild. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-sandbox) SANDBOXED=0; shift ;; + --scratch-dir) SCRATCH_DIR="$2"; shift 2 ;; + --vcpkg-cache) VCPKG_CACHE_DIR="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + --) shift; EXTRA_ARGS+=("$@"); break ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +branch="$(git -C "${ROOT}" rev-parse --abbrev-ref HEAD)" +repo_source="file://${ROOT}#branch=${branch}" + +mkdir -p "${SCRATCH_DIR}" "${VCPKG_CACHE_DIR}" +sed \ + -e "s#@REPO_SOURCE@#${repo_source}#" \ + -e "s#@VCPKG_CACHE_DIR@#${VCPKG_CACHE_DIR}#" \ + "${PKG_TEMPLATE}" > "${SCRATCH_DIR}/PKGBUILD" +cp "${SERVICE_FILE}" "${SCRATCH_DIR}/elpd.service" + +echo "==> Rendered PKGBUILD in ${SCRATCH_DIR} (source: ${repo_source})" +echo "==> vcpkg binary cache: ${VCPKG_CACHE_DIR}" +cd "${SCRATCH_DIR}" + +if [[ "${SANDBOXED}" -eq 1 ]]; then + command -v extra-x86_64-build >/dev/null 2>&1 || { + echo "error: extra-x86_64-build not found; install 'devtools' or pass --no-sandbox" >&2 + exit 1 + } + echo "==> Building in a sandboxed extra-x86_64 chroot" + extra-x86_64-build -- -d "${VCPKG_CACHE_DIR}" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} +else + echo "==> Building with plain makepkg (not sandboxed)" + makepkg -sf --noconfirm ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} +fi + +echo "==> Done. Package(s):" +ls -1 ./*.pkg.tar.* 2>/dev/null || true From c77a5f456cc2f535dd9f9224f6df5a6ecb526167 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 02:04:46 +0200 Subject: [PATCH 02/27] Fix build-arch.sh PKGBUILD templating with a '#' in the git ref source repo_source (file://...#branch=...) contains a literal '#', which broke the '#'-delimited sed s### substitution. Use bash parameter substitution instead, which has no delimiter to collide with. --- scripts/build-arch.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/build-arch.sh b/scripts/build-arch.sh index d298c25293..9a3e039480 100755 --- a/scripts/build-arch.sh +++ b/scripts/build-arch.sh @@ -55,10 +55,12 @@ branch="$(git -C "${ROOT}" rev-parse --abbrev-ref HEAD)" repo_source="file://${ROOT}#branch=${branch}" mkdir -p "${SCRATCH_DIR}" "${VCPKG_CACHE_DIR}" -sed \ - -e "s#@REPO_SOURCE@#${repo_source}#" \ - -e "s#@VCPKG_CACHE_DIR@#${VCPKG_CACHE_DIR}#" \ - "${PKG_TEMPLATE}" > "${SCRATCH_DIR}/PKGBUILD" +# Plain bash substitution (not sed): repo_source contains '#' (the git +# ref fragment), which breaks a '#'-delimited sed s### expression. +rendered="$(cat "${PKG_TEMPLATE}")" +rendered="${rendered//@REPO_SOURCE@/${repo_source}}" +rendered="${rendered//@VCPKG_CACHE_DIR@/${VCPKG_CACHE_DIR}}" +printf '%s\n' "${rendered}" > "${SCRATCH_DIR}/PKGBUILD" cp "${SERVICE_FILE}" "${SCRATCH_DIR}/elpd.service" echo "==> Rendered PKGBUILD in ${SCRATCH_DIR} (source: ${repo_source})" From 4edabeb53a253efd185ed4fda2997e4c8076826b Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 02:09:53 +0200 Subject: [PATCH 03/27] Fix PKGBUILD pkgver()/CMake versioning for a tagless checkout This checkout has no git tags, so `git describe --tags --long` fails: - pkgver()'s previous `... | sed ... || echo 0.0.0` fallback never triggered (the pipeline's exit status is sed's, not git describe's), so pkgver() returned an empty string and makepkg aborted. - The project's own CMake versioning (src/cmake/versioning.cmake) hits the same wall and errors out during configure. Fix pkgver()'s fallback properly, and have prepare() tag the disposable build clone locally (never the original repo) so both pkgver() and versioning.cmake's own `git describe` succeed unmodified. --- packaging/archlinux/PKGBUILD.in | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index f7d5460fbd..2bdb045ac2 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -78,12 +78,24 @@ sha256sums=('SKIP') pkgver() { cd "${srcdir}/elp" - git describe --tags --long 2>/dev/null | sed 's/^v//; s/-/./g' || echo 0.0.0 + local v + v="$(git describe --tags --long 2>/dev/null)" || v="" + if [[ -n "${v}" ]]; then + echo "${v}" | sed 's/^v//; s/-/./g' + else + printf '0.0.0.r%s.g%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short=8 HEAD)" + fi } prepare() { cd "${srcdir}/elp" git submodule update --init --recursive + # This checkout has no tags of its own; both pkgver() above and the + # project's own CMake versioning (src/cmake/versioning.cmake) need a + # usable `git describe`. Tag the disposable build clone locally (this + # never touches the repository it was cloned from) so both work + # unmodified; the pkgver() fallback above still covers this failing. + git describe --tags >/dev/null 2>&1 || git tag v0.0.0-dev } build() { From c2b71e76e633448a01ec86d01bea4348f64ae3e0 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 02:39:24 +0200 Subject: [PATCH 04/27] Add missing meson/flex/libcap-ng/attr makedepends for the vendored QEMU build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vcpkg qemu port's Linux path runs QEMU's real upstream ./configure (3rd-party/vcpkg-ports/qemu/portfile.cmake), which for QEMU >=8 shells out to meson to generate the actual build — meson was missing from makedepends, which is why ./configure failed. --enable-virtfs also needs libcap-ng/attr, and the vendored dtc subproject (patch 0005) needs flex alongside bison. --- packaging/archlinux/PKGBUILD.in | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index 2bdb045ac2..7b81258706 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -40,6 +40,8 @@ makedepends=( 'llvm' 'lld' 'bison' + 'flex' # dtc subproject (vendored by the qemu port's patch 0005) + 'meson' # QEMU >=8's ./configure shells out to meson to generate the build 'autoconf-archive' 'curl' 'zip' @@ -47,6 +49,8 @@ makedepends=( 'python' 'apparmor' # libapparmor headers 'systemd-libs' # libsystemd headers + 'libcap-ng' # qemu --enable-virtfs + 'attr' # qemu --enable-virtfs (xattr support) # vcpkg builds Qt6/gRPC/Boost/qemu from source (see ../../vcpkg.json); these # are the system headers those ports need to build on Linux: 'libxkbcommon' From 555f35f505934b16f46d382475fc734042c18d6f Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 12:14:25 +0200 Subject: [PATCH 05/27] Add python-distlib makedepend for QEMU's mkvenv bootstrap configure's actual failure (found via config-x64-linux-release-rel-err.log): "found no usable distlib, please install it" from QEMU's mkvenv step. Arch's python package ships the PEP 668 EXTERNALLY-MANAGED marker, so mkvenv's own pip-install fallback for distlib is blocked; installing python-distlib as a system package satisfies the check without pip. --- packaging/archlinux/PKGBUILD.in | 1 + 1 file changed, 1 insertion(+) diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index 7b81258706..df3c45a39a 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -47,6 +47,7 @@ makedepends=( 'zip' 'unzip' 'python' + 'python-distlib' # qemu's mkvenv needs distlib; pip-installing it hits Arch's PEP 668 lock 'apparmor' # libapparmor headers 'systemd-libs' # libsystemd headers 'libcap-ng' # qemu --enable-virtfs From 689c9d6d0907bdb835a8d5760aeafdf6a0203b57 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 12:23:26 +0200 Subject: [PATCH 06/27] Use an annotated git tag so CMake's versioning.cmake can describe it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit versioning.cmake calls plain \`git describe\` (no --tags), which only considers annotated tags — the lightweight tag prepare() created wasn't enough, even though pkgver()'s own \`git describe --tags\` accepted it. Create an annotated tag instead, with an inline committer identity since a fresh chroot's git has no global user.name/user.email configured. --- packaging/archlinux/PKGBUILD.in | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index df3c45a39a..c0cdf88173 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -100,7 +100,13 @@ prepare() { # usable `git describe`. Tag the disposable build clone locally (this # never touches the repository it was cloned from) so both work # unmodified; the pkgver() fallback above still covers this failing. - git describe --tags >/dev/null 2>&1 || git tag v0.0.0-dev + # Must be annotated: versioning.cmake calls plain `git describe` (no + # --tags), which ignores lightweight tags. An annotated tag needs a + # committer identity, which a fresh chroot's git has none of, so set + # one inline rather than depending on any global gitconfig. + git describe --tags >/dev/null 2>&1 || \ + git -c user.name="build" -c user.email="build@localhost" \ + tag -a v0.0.0-dev -m "local build" } build() { From 1965912b1f825172c55931daf1a12e405986c013 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 12:30:31 +0200 Subject: [PATCH 07/27] Use v1.0.0-dev as the local dev tag, not v0.0.0-dev CMakeLists.txt's own version check, \`if (NOT CMAKE_MATCH_1)\`, treats the *string* "0" as false in CMake's if(), so a parsed major version of "0" makes it wrongly report "Failed to parse MAJOR.MINOR.PATCH" even though the regex matched correctly. Avoid tripping that latent bug by tagging with a non-zero major instead of touching the main CMakeLists.txt. --- packaging/archlinux/PKGBUILD.in | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index c0cdf88173..1b6dc530bb 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -103,10 +103,13 @@ prepare() { # Must be annotated: versioning.cmake calls plain `git describe` (no # --tags), which ignores lightweight tags. An annotated tag needs a # committer identity, which a fresh chroot's git has none of, so set - # one inline rather than depending on any global gitconfig. + # one inline rather than depending on any global gitconfig. Major + # version can't be 0: CMakeLists.txt's own `if (NOT CMAKE_MATCH_1)` + # check treats the *string* "0" as false, so a v0.x.y tag makes it + # wrongly report "failed to parse" on an otherwise-valid version. git describe --tags >/dev/null 2>&1 || \ git -c user.name="build" -c user.email="build@localhost" \ - tag -a v0.0.0-dev -m "local build" + tag -a v1.0.0-dev -m "local build" } build() { From b340ed6300f85e379658a145be662c377668b469 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 12:38:30 +0200 Subject: [PATCH 08/27] Add rust makedepend for the rxx/ cargo build CMake's rxx/CMakeLists.txt find_program()s cargo directly; BUILD.linux.md covers installing it via rustup for a manual dev build, but the PKGBUILD never declared it. Arch's rust package provides both rustc and cargo. --- packaging/archlinux/PKGBUILD.in | 1 + 1 file changed, 1 insertion(+) diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index 1b6dc530bb..f0764dc21e 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -36,6 +36,7 @@ makedepends=( 'cmake' 'ninja' 'pkgconf' + 'rust' # rxx/ Rust component built via cargo (BUILD.linux.md's rustup step) 'clang' 'llvm' 'lld' From 3bffbc09971a74a821aff6156d5480e6d6878b22 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 12:48:33 +0200 Subject: [PATCH 09/27] Declare libkeybinder3 (AUR) as a runtime dependency, document the AUR step The GUI's hotkey_manager Flutter plugin requires keybinder-3.0 (libkeybinder3), which is AUR-only. A sandboxed extra-x86_64-build chroot can't reach the AUR, so document building it once locally and injecting it into the chroot via makechrootpkg's -I, in both the PKGBUILD comments and BUILD.linux.md. Also declare it in `depends` since the GUI links against it at runtime, not just at build time. --- BUILD.linux.md | 12 ++++++++++++ packaging/archlinux/PKGBUILD.in | 13 +++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/BUILD.linux.md b/BUILD.linux.md index 90f4bc4fc7..e1f2b6e488 100644 --- a/BUILD.linux.md +++ b/BUILD.linux.md @@ -25,6 +25,18 @@ chroot and points vcpkg's binary cache at it, so later builds, even in a freshly recreated chroot, reuse those binaries instead of recompiling them. Delete that directory to force a full rebuild from scratch. +One dependency, `libkeybinder3` (needed by the GUI's `hotkey_manager` +Flutter plugin), is AUR-only — a sandboxed chroot can't reach the AUR +itself, so build and inject it once before the first build: + +``` +git clone https://aur.archlinux.org/libkeybinder3.git /tmp/libkeybinder3 +(cd /tmp/libkeybinder3 && makepkg -s) +./scripts/build-arch.sh -- -I /tmp/libkeybinder3/libkeybinder3-*.pkg.tar.zst +``` + +(For `--no-sandbox` builds, just `makepkg -si` it normally instead.) + Install the resulting package with `pacman -U .pkg.tar.zst`; this installs `elpd`/`elp`/the GUI, a systemd unit (`systemctl enable --now elpd`), and desktop/icon/completion files. diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index f0764dc21e..fb5688f49a 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -30,6 +30,7 @@ depends=( 'libnotify' 'libayatana-appindicator' 'gtkmm3' + 'libkeybinder3' # AUR — GUI's hotkey_manager plugin links keybinder-3.0 at runtime too ) makedepends=( 'git' @@ -74,10 +75,14 @@ makedepends=( 'glib2' 'pixman' ) -# Not in the official repos as of writing; install from the AUR first -# (e.g. `yay -S libkeybinder3`) or the build will fail to configure gtkmm's -# tray-icon integration: -# libkeybinder3 (AUR) +# libkeybinder3 (declared in depends above, for the GUI's hotkey_manager +# Flutter plugin) is AUR-only, not in the official repos. A sandboxed +# extra-x86_64-build chroot can't reach the AUR itself, so build it and +# inject it into the chroot first: +# git clone https://aur.archlinux.org/libkeybinder3.git /tmp/libkeybinder3 +# (cd /tmp/libkeybinder3 && makepkg -s) +# ./scripts/build-arch.sh -- -I /tmp/libkeybinder3/libkeybinder3-*.pkg.tar.zst +# For --no-sandbox builds, just `makepkg -si` it normally first. options=('!lto') # heavy C++ TUs (Qt/gRPC-generated code); LTO here is not worth the build time source=("elp::git+@REPO_SOURCE@") sha256sums=('SKIP') From 9b9af0fc0e547bb420f9dc5bf60ae4c44302a3b1 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 13:01:52 +0200 Subject: [PATCH 10/27] Relax two -Werror warnings that only newer toolchains trigger Building on Arch's newer Clang/GTK surfaces two -Werror failures that this project's usual (older/Ubuntu) toolchain never hits: - my_application.cc calls gdk_pixbuf_new_from_xpm_data, deprecated in GTK >=2.44. - The vendored hotkey_manager_linux Flutter plugin (fetched from pub.dev, not part of this repo) has a genuine but harmless uninitialized-variable-on-one-branch bug, caught by Clang's -Wsometimes-uninitialized (GCC: -Wmaybe-uninitialized). apply_standard_settings() in src/client/gui/linux/CMakeLists.txt is shared by the main GUI binary and every plugin target (per its own comment), so downgrading just these two warning classes to non-fatal here fixes both without touching the third-party plugin's source or weakening -Wall/-Werror generally. --- src/client/gui/linux/CMakeLists.txt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/client/gui/linux/CMakeLists.txt b/src/client/gui/linux/CMakeLists.txt index a2c6937c4a..757aac0506 100644 --- a/src/client/gui/linux/CMakeLists.txt +++ b/src/client/gui/linux/CMakeLists.txt @@ -41,7 +41,16 @@ endif() # of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE -Wall -Werror + # Newer GTK (>=2.44) deprecates gdk_pixbuf_new_from_xpm_data (used by + # my_application.cc); newer Clang's -Wsometimes-uninitialized (GCC: + # -Wmaybe-uninitialized) flags a genuine but harmless latent bug in the + # vendored hotkey_manager_linux plugin. Neither is worth failing the + # build over on a newer toolchain. + -Wno-error=deprecated-declarations + "$<$:-Wno-error=sometimes-uninitialized>" + "$<$:-Wno-error=maybe-uninitialized>" + ) target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") endfunction() From 68d82eb166cd5dcf3ccc42d10f7c19c57e03c5f2 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 13:18:07 +0200 Subject: [PATCH 11/27] Silence a GCC 16 false-positive -Werror=array-bounds in elp_api Newer GCC (GCC 16, as shipped by CachyOS/Arch) mis-analyzes object layout while inlining httplib::SSLClient's destructor across vcpkg's vendored cpp-httplib header (httplib.h) in openai.cpp's proxy_to_backend, reporting bogus out-of-bounds array subscripts on an object it itself just correctly sized via operator new. Scope -Wno-error=array-bounds to the elp_api target only (GCC only), keeping -Warray-bounds as an error everywhere else in the codebase where it's still a useful check. --- src/api/CMakeLists.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/api/CMakeLists.txt b/src/api/CMakeLists.txt index 7dd3cac90a..7f6b34c1d9 100644 --- a/src/api/CMakeLists.txt +++ b/src/api/CMakeLists.txt @@ -44,6 +44,13 @@ target_compile_definitions(elp_api CPPHTTPLIB_OPENSSL_SUPPORT CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES) +# Newer GCC (e.g. GCC 16) mis-analyzes object layout while inlining +# httplib::SSLClient's destructor across the vendored cpp-httplib header, +# producing a false-positive -Warray-bounds. Scoped to this target only, +# since -Warray-bounds is still worth keeping as an error elsewhere. +target_compile_options(elp_api PRIVATE + "$<$:-Wno-error=array-bounds>") + target_link_libraries(elp_api PUBLIC client_common From 86108a9cdcdae6bc3492230d9698ac727b191c71 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 14:16:50 +0200 Subject: [PATCH 12/27] Declare elpd.service as a proper local source, not a bare sibling file package() failed with "cannot stat '/build/elp/src/../elpd.service'": a sandboxed extra-x86_64-build chroot only stages the PKGBUILD plus its own source=() entries into the container, not arbitrary files that merely happen to sit next to it on the host (which is how scripts/build-arch.sh places elpd.service). Declare it as a second source=() entry so devtools' staging step copies it in, and reference it via the srcdir-relative path makepkg actually puts it at. The rest of the build succeeded end to end on this run: elpd, elp, the GUI (including the AUR libkeybinder3 dependency), and elp-api all compiled and linked; only packaging the systemd unit failed. --- packaging/archlinux/PKGBUILD.in | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index fb5688f49a..bde71d6235 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -84,8 +84,12 @@ makedepends=( # ./scripts/build-arch.sh -- -I /tmp/libkeybinder3/libkeybinder3-*.pkg.tar.zst # For --no-sandbox builds, just `makepkg -si` it normally first. options=('!lto') # heavy C++ TUs (Qt/gRPC-generated code); LTO here is not worth the build time -source=("elp::git+@REPO_SOURCE@") -sha256sums=('SKIP') +# elpd.service must be a declared local source (not just a sibling file +# scripts/build-arch.sh copies next to this PKGBUILD): a sandboxed +# extra-x86_64-build chroot only stages the PKGBUILD plus its own +# source=() entries, nothing else that happens to sit alongside it. +source=("elp::git+@REPO_SOURCE@" "elpd.service") +sha256sums=('SKIP' 'SKIP') pkgver() { cd "${srcdir}/elp" @@ -136,7 +140,7 @@ build() { package() { DESTDIR="${pkgdir}" cmake --install "${srcdir}/build" - install -Dm644 "${srcdir}/../elpd.service" \ + install -Dm644 "${srcdir}/elpd.service" \ "${pkgdir}/usr/lib/systemd/system/elpd.service" install -Dm644 "${srcdir}/elp/data/elp.gui.desktop" \ "${pkgdir}/usr/share/applications/elp.gui.desktop" From 9020bff3064331abeb74edef30a8707b3bd2e61b Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 14:43:47 +0200 Subject: [PATCH 13/27] Fix namcap-found packaging defects: license, deps, RPATH leaks, debug bloat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build now succeeds end to end and pacman -U installs it, but namcap (run automatically by extra-x86_64-build) found real defects that would break the package on any machine other than the one it was built on: - license=('GPL3') isn't a valid SPDX identifier; use GPL-3.0-only (matches this repo's file headers: "version 3" with no "or later"). - libsecret (flutter_secure_storage_linux) and apparmor (libapparmor.so.1, used by elpd/libdart_ffi.so) were linked at runtime but missing from depends (apparmor was only in makedepends). - Several Flutter plugin .so files and the bundled libdart_ffi.so had absolute build-tree RPATHs baked in (e.g. /build/elp/src/build/vcpkg_installed/...): flutter build linux is a custom command, not a normal install(TARGETS), so CMake's own install-time RPATH rewrite never touches these files. patchelf them to $ORIGIN in package() instead (patchelf added to makedepends). - sshfs_server (src/sshfs_mount/CMakeLists.txt) set INSTALL_RPATH to the macOS-only @executable_path token unconditionally, which is a silent no-op on Linux; use $ORIGIN there instead, keeping @executable_path only under APPLE. - The debug package was ~4.7GB because vcpkg's entire buildtree (Qt6, gRPC, QEMU, ...) was being swept into debug symbols; !debug in options skips generating it. Drop the libkeybinder3 AUR workaround: it's in Arch's official extra repo Verified via 'pacman -Si libkeybinder3' (present in [extra], packaged by an Arch dev, not AUR-only as previously assumed). makepkg now resolves and installs it automatically as a normal depends entry — no more manual build-and-inject step needed before running scripts/build-arch.sh. Drop redundant apparmor makedepends entry (namcap: already in depends) Being in depends already pulls it in at build time too; listing it in both arrays just tripped namcap's redundant-makedepend check. --- BUILD.linux.md | 12 ------------ packaging/archlinux/PKGBUILD.in | 30 ++++++++++++++++++------------ src/sshfs_mount/CMakeLists.txt | 12 ++++++++++-- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/BUILD.linux.md b/BUILD.linux.md index e1f2b6e488..90f4bc4fc7 100644 --- a/BUILD.linux.md +++ b/BUILD.linux.md @@ -25,18 +25,6 @@ chroot and points vcpkg's binary cache at it, so later builds, even in a freshly recreated chroot, reuse those binaries instead of recompiling them. Delete that directory to force a full rebuild from scratch. -One dependency, `libkeybinder3` (needed by the GUI's `hotkey_manager` -Flutter plugin), is AUR-only — a sandboxed chroot can't reach the AUR -itself, so build and inject it once before the first build: - -``` -git clone https://aur.archlinux.org/libkeybinder3.git /tmp/libkeybinder3 -(cd /tmp/libkeybinder3 && makepkg -s) -./scripts/build-arch.sh -- -I /tmp/libkeybinder3/libkeybinder3-*.pkg.tar.zst -``` - -(For `--no-sandbox` builds, just `makepkg -si` it normally instead.) - Install the resulting package with `pacman -U .pkg.tar.zst`; this installs `elpd`/`elp`/the GUI, a systemd unit (`systemctl enable --now elpd`), and desktop/icon/completion files. diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index bde71d6235..5a87e4bc26 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -12,7 +12,7 @@ pkgrel=1 pkgdesc="Electros LaunchPad: create, control and connect to Linux VM instances (daemon, CLI and GUI)" arch=('x86_64') url="https://elemento.cloud" -license=('GPL3') +license=('GPL-3.0-only') depends=( 'qt6-base' 'gcc-libs' @@ -30,7 +30,9 @@ depends=( 'libnotify' 'libayatana-appindicator' 'gtkmm3' - 'libkeybinder3' # AUR — GUI's hotkey_manager plugin links keybinder-3.0 at runtime too + 'libkeybinder3' # GUI's hotkey_manager plugin links keybinder-3.0 at runtime + 'libsecret' # flutter_secure_storage_linux plugin + 'apparmor' # libapparmor.so.1, linked by elpd/libdart_ffi.so at runtime too ) makedepends=( 'git' @@ -50,8 +52,8 @@ makedepends=( 'unzip' 'python' 'python-distlib' # qemu's mkvenv needs distlib; pip-installing it hits Arch's PEP 668 lock - 'apparmor' # libapparmor headers 'systemd-libs' # libsystemd headers + 'patchelf' # rewrite build-tree RPATHs the Flutter plugin build bakes in raw 'libcap-ng' # qemu --enable-virtfs 'attr' # qemu --enable-virtfs (xattr support) # vcpkg builds Qt6/gRPC/Boost/qemu from source (see ../../vcpkg.json); these @@ -75,15 +77,8 @@ makedepends=( 'glib2' 'pixman' ) -# libkeybinder3 (declared in depends above, for the GUI's hotkey_manager -# Flutter plugin) is AUR-only, not in the official repos. A sandboxed -# extra-x86_64-build chroot can't reach the AUR itself, so build it and -# inject it into the chroot first: -# git clone https://aur.archlinux.org/libkeybinder3.git /tmp/libkeybinder3 -# (cd /tmp/libkeybinder3 && makepkg -s) -# ./scripts/build-arch.sh -- -I /tmp/libkeybinder3/libkeybinder3-*.pkg.tar.zst -# For --no-sandbox builds, just `makepkg -si` it normally first. -options=('!lto') # heavy C++ TUs (Qt/gRPC-generated code); LTO here is not worth the build time +options=('!lto' '!debug') # LTO not worth the build time here; !debug skips shipping vcpkg's + # entire buildtree (Qt6/gRPC/QEMU/...) as debug symbols (~5GB) # elpd.service must be a declared local source (not just a sibling file # scripts/build-arch.sh copies next to this PKGBUILD): a sandboxed # extra-x86_64-build chroot only stages the PKGBUILD plus its own @@ -140,6 +135,17 @@ build() { package() { DESTDIR="${pkgdir}" cmake --install "${srcdir}/build" + # The Flutter Linux plugin build (flutter build linux, a custom command, + # not a normal CMake install(TARGETS)) bakes in absolute build-tree + # RPATHs that CMake's own install-time RPATH rewrite never gets a chance + # to strip, since these files are just directory-copied into the bundle. + # All of a bundle's libs live side by side, so $ORIGIN alone is enough. + local f + for f in "${pkgdir}/usr/bin/bundle/lib/"*plugin.so \ + "${pkgdir}/usr/bin/bundle/lib/libdart_ffi.so"; do + [[ -e "${f}" ]] && patchelf --set-rpath '$ORIGIN' "${f}" + done + install -Dm644 "${srcdir}/elpd.service" \ "${pkgdir}/usr/lib/systemd/system/elpd.service" install -Dm644 "${srcdir}/elp/data/elp.gui.desktop" \ diff --git a/src/sshfs_mount/CMakeLists.txt b/src/sshfs_mount/CMakeLists.txt index a0be1ba4e6..25e279ba1e 100644 --- a/src/sshfs_mount/CMakeLists.txt +++ b/src/sshfs_mount/CMakeLists.txt @@ -60,8 +60,16 @@ target_link_libraries(sshfs_server logger sshfs_mount) -set_target_properties(sshfs_server - PROPERTIES INSTALL_RPATH "@executable_path/../lib") +if(APPLE) + set_target_properties(sshfs_server + PROPERTIES INSTALL_RPATH "@executable_path/../lib") +else() + # @executable_path is a macOS/dyld-only token; the ELF loader on Linux + # (and anywhere else using ld.so) doesn't understand it, so this was a + # silent no-op there. Use the ELF equivalent instead. + set_target_properties(sshfs_server + PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") +endif() target_include_directories(sshfs_server BEFORE From 6812a85853bfc4d900411273814b74ad54c8b890 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 15:37:26 +0200 Subject: [PATCH 14/27] Add the "intent" feature: named groups of instances (daemon, RPC, CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements feature 2 of the anvil plan: `elp intent create --service redis --service postgres` launches a named group of instances together, tagging each with its intent/role; `elp intent list|info|delete` manage the group. - Proto: 4 new RPCs (intent_create/list/info/delete) plus `intent`/ `intent_role` fields on LaunchRequest/DetailedInfoItem so membership is visible via the existing launch/info/list surface too. - IntentSpec (include/multipass/intent_spec.h, src/utils/intent_spec.cpp): a lightweight registry (name + ordered members + creation time), persisted to multipassd-intents.json next to the existing multipassd-vm-instances.json, following the same tag_invoke/ MP_FILEOPS.write_transactionally pattern as VMSpecs. - Daemon::intent_create reuses the existing, well-tested Daemon::create_vm for each member rather than duplicating VM provisioning. create_vm is asynchronous (QFutureWatcher-based), so naively looping and blocking on each member from the daemon's own thread would deadlock; instead each member's completion (via a one-shot, self-deleting DaemonRpcContext) chains the next member's launch, and the last one completes the RPC. Progress is forwarded into the client-visible IntentCreateReply stream via a small ServerReaderWriterInterface adapter. - Two starter service templates (redis, postgres) are embedded in src/daemon/intent_service_templates.cpp (data/cloud-init-yaml/cloud-init- {redis,postgres}.yaml are kept as the human-readable reference copies); `elp intent create` also accepts fully custom --instance members. - CLI: src/client/cli/cmd/intent.{h,cpp}, one command with create/list/ info/delete actions, registered in client.cpp. Not yet built/tested — proto codegen and Qt signal/slot wiring need a real compile to catch what a read-only review can't. GUI integration (launch form intent picker, list filter) is deferred to a later pass per the plan. --- data/cloud-init-yaml/cloud-init-postgres.yaml | 14 + data/cloud-init-yaml/cloud-init-redis.yaml | 12 + include/multipass/intent_spec.h | 55 +++ src/client/cli/client.cpp | 2 + src/client/cli/cmd/CMakeLists.txt | 1 + src/client/cli/cmd/intent.cpp | 277 ++++++++++++++ src/client/cli/cmd/intent.h | 40 ++ src/daemon/CMakeLists.txt | 1 + src/daemon/daemon.cpp | 359 ++++++++++++++++++ src/daemon/daemon.h | 23 ++ src/daemon/daemon_rpc.cpp | 52 +++ src/daemon/daemon_rpc.h | 24 ++ src/daemon/intent_service_templates.cpp | 56 +++ src/daemon/intent_service_templates.h | 38 ++ src/rpc/multipass.proto | 77 ++++ src/utils/CMakeLists.txt | 1 + src/utils/intent_spec.cpp | 53 +++ 17 files changed, 1085 insertions(+) create mode 100644 data/cloud-init-yaml/cloud-init-postgres.yaml create mode 100644 data/cloud-init-yaml/cloud-init-redis.yaml create mode 100644 include/multipass/intent_spec.h create mode 100644 src/client/cli/cmd/intent.cpp create mode 100644 src/client/cli/cmd/intent.h create mode 100644 src/daemon/intent_service_templates.cpp create mode 100644 src/daemon/intent_service_templates.h create mode 100644 src/utils/intent_spec.cpp diff --git a/data/cloud-init-yaml/cloud-init-postgres.yaml b/data/cloud-init-yaml/cloud-init-postgres.yaml new file mode 100644 index 0000000000..aff2e3c4ac --- /dev/null +++ b/data/cloud-init-yaml/cloud-init-postgres.yaml @@ -0,0 +1,14 @@ +# Reference copy of the "postgres" service template used by `elp intent +# create --service postgres`; the daemon embeds this content directly +# (src/daemon/intent_service_templates.cpp) rather than reading this file. +packages: +- postgresql + +runcmd: +- | + echo "listen_addresses = '*'" >> /etc/postgresql/*/main/postgresql.conf + echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/*/main/pg_hba.conf + systemctl enable postgresql --now + systemctl restart postgresql + +final_message: "postgres is up, after $UPTIME seconds" diff --git a/data/cloud-init-yaml/cloud-init-redis.yaml b/data/cloud-init-yaml/cloud-init-redis.yaml new file mode 100644 index 0000000000..706d3bdc58 --- /dev/null +++ b/data/cloud-init-yaml/cloud-init-redis.yaml @@ -0,0 +1,12 @@ +# Reference copy of the "redis" service template used by `elp intent create +# --service redis`; the daemon embeds this content directly +# (src/daemon/intent_service_templates.cpp) rather than reading this file. +packages: +- redis-server + +runcmd: +- | + sed -i 's/^bind 127.0.0.1.*/bind 0.0.0.0/' /etc/redis/redis.conf + systemctl enable redis-server --now + +final_message: "redis is up, after $UPTIME seconds" diff --git a/include/multipass/intent_spec.h b/include/multipass/intent_spec.h new file mode 100644 index 0000000000..6f007474a5 --- /dev/null +++ b/include/multipass/intent_spec.h @@ -0,0 +1,55 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#pragma once + +#include + +#include +#include + +namespace multipass +{ + +// A named group of instances launched together (e.g. "test-app-1" made of a +// redis and a postgres instance). Membership is also mirrored into each +// member instance's own VMSpecs.metadata ("intent"/"intent_role"); this +// registry is the source of truth for the group itself (name, ordered +// members, creation time), independent of any single member. +struct IntentSpec +{ + struct Member + { + std::string role; // e.g. "redis", or a user-chosen label for inline members + std::string instance_name; // the actual launched instance's name + + friend inline bool operator==(const Member&, const Member&) = default; + }; + + std::string name; + std::vector members; + std::string creation_timestamp; // ISO-8601 + + friend inline bool operator==(const IntentSpec&, const IntentSpec&) = default; +}; + +void tag_invoke(const boost::json::value_from_tag&, + boost::json::value& json, + const IntentSpec& spec); +IntentSpec tag_invoke(const boost::json::value_to_tag&, const boost::json::value& json); + +} // namespace multipass diff --git a/src/client/cli/client.cpp b/src/client/cli/client.cpp index edf65b328c..6080791b58 100644 --- a/src/client/cli/client.cpp +++ b/src/client/cli/client.cpp @@ -28,6 +28,7 @@ #include "cmd/get.h" #include "cmd/help.h" #include "cmd/info.h" +#include "cmd/intent.h" #include "cmd/launch.h" #include "cmd/list.h" #include "cmd/llm.h" @@ -119,6 +120,7 @@ mp::Client::Client(ClientConfig& config) add_command(); add_command(); add_command(); + add_command(); add_command(); add_command(); add_command(); diff --git a/src/client/cli/cmd/CMakeLists.txt b/src/client/cli/cmd/CMakeLists.txt index eb44283dfc..a6cc714956 100644 --- a/src/client/cli/cmd/CMakeLists.txt +++ b/src/client/cli/cmd/CMakeLists.txt @@ -28,6 +28,7 @@ add_library(commands STATIC get.cpp help.cpp info.cpp + intent.cpp launch.cpp list.cpp llm.cpp diff --git a/src/client/cli/cmd/intent.cpp b/src/client/cli/cmd/intent.cpp new file mode 100644 index 0000000000..949dd4b909 --- /dev/null +++ b/src/client/cli/cmd/intent.cpp @@ -0,0 +1,277 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include "intent.h" + +#include "animated_spinner.h" +#include "common_cli.h" + +#include +#include + +#include +#include + +namespace mp = multipass; +namespace cmd = multipass::cmd; + +namespace +{ +const QCommandLineOption service_option{ + "service", + "Add a member using a known service template (e.g. redis, postgres). Can be repeated.", + "role"}; +const QCommandLineOption instance_option{ + "instance", + "Add a custom member as role:image:cloud-init-file:cores:mem:disk (trailing fields " + "optional, e.g. \"cache:22.04::1:1G:5G\"). Can be repeated.", + "spec"}; +const QCommandLineOption purge_option{"purge", "Also purge deleted member instances immediately."}; + +std::string instance_status_name(mp::InstanceStatus::Status status) +{ + switch (status) + { + case mp::InstanceStatus::RUNNING: + return "Running"; + case mp::InstanceStatus::STOPPED: + return "Stopped"; + case mp::InstanceStatus::DELETED: + return "Deleted"; + case mp::InstanceStatus::SUSPENDED: + return "Suspended"; + case mp::InstanceStatus::SUSPENDING: + return "Suspending"; + default: + return "Unknown"; + } +} +} // namespace + +mp::ReturnCodeVariant cmd::Intent::run(ArgParser* parser) +{ + parser->addPositionalArgument("action", "create | list | info | delete", ""); + parser->addPositionalArgument("name", "The intent's name (not needed for `list`)", "[]"); + parser->addOption(service_option); + parser->addOption(instance_option); + parser->addOption(purge_option); + + const auto status = parser->commandParse(this); + if (status != ParseCode::Ok) + return parser->returnCodeFrom(status); + + const auto args = parser->positionalArguments(); + if (args.empty()) + { + cerr << "Please specify an action: create, list, info, or delete.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + + const auto action = args[0].toStdString(); + if (action == "create") + return run_create(parser); + if (action == "list") + return run_list(parser); + if (action == "info") + return run_info(parser); + if (action == "delete") + return run_delete(parser); + + cerr << fmt::format("Unknown action \"{}\"; expected create, list, info, or delete.\n", action); + return parser->returnCodeFrom(ParseCode::CommandLineError); +} + +std::string cmd::Intent::name() const +{ + return "intent"; +} + +QString cmd::Intent::short_help() const +{ + return QStringLiteral("Create and manage intents (named groups of instances)"); +} + +QString cmd::Intent::description() const +{ + return QStringLiteral( + "Create a named group of instances launched together (e.g. a \"redis\" and a " + "\"postgres\" instance for an app), and list, inspect, or delete such groups.\n\n" + " elp intent create --service redis --service postgres\n" + " elp intent list\n" + " elp intent info \n" + " elp intent delete [--purge]"); +} + +mp::ReturnCodeVariant cmd::Intent::run_create(ArgParser* parser) +{ + const auto args = parser->positionalArguments(); + if (args.size() < 2) + { + cerr << "Please provide a name for the intent.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + if (!parser->isSet(service_option) && !parser->isSet(instance_option)) + { + cerr << "Please specify at least one member with --service or --instance.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + + IntentCreateRequest request; + request.set_name(args[1].toStdString()); + request.set_verbosity_level(parser->verbosityLevel()); + + for (const auto& role : parser->values(service_option)) + { + auto* member = request.add_members(); + member->set_role(role.toStdString()); + } + + for (const auto& spec : parser->values(instance_option)) + { + const auto fields = spec.split(':'); + if (fields.size() < 1 || fields[0].isEmpty()) + { + cerr << fmt::format("Invalid --instance spec \"{}\"; expected " + "role:image:cloud-init-file:cores:mem:disk.\n", + spec.toStdString()); + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + + auto* member = request.add_members(); + member->set_role(fields[0].toStdString()); + if (fields.size() > 1 && !fields[1].isEmpty()) + member->set_image(fields[1].toStdString()); + if (fields.size() > 2 && !fields[2].isEmpty()) + { + QFile file{fields[2]}; + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + cerr << fmt::format("Could not read cloud-init file \"{}\".\n", + fields[2].toStdString()); + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + member->set_cloud_init_user_data(QTextStream{&file}.readAll().toStdString()); + } + if (fields.size() > 3 && !fields[3].isEmpty()) + member->set_num_cores(fields[3].toInt()); + if (fields.size() > 4 && !fields[4].isEmpty()) + member->set_mem_size(fields[4].toStdString()); + if (fields.size() > 5 && !fields[5].isEmpty()) + member->set_disk_space(fields[5].toStdString()); + } + + AnimatedSpinner spinner{cout}; + auto on_success = [this, &spinner](IntentCreateReply& reply) -> ReturnCodeVariant { + spinner.stop(); + cout << reply.reply_message() << "\n"; + return ReturnCode::Ok; + }; + auto on_failure = [this, &spinner](grpc::Status& status, + IntentCreateReply& reply) -> ReturnCodeVariant { + spinner.stop(); + return standard_failure_handler_for(name(), cerr, status, reply.reply_message()); + }; + + spinner.start("Creating intent " + request.name()); + return dispatch(&RpcMethod::intent_create, request, on_success, on_failure); +} + +mp::ReturnCodeVariant cmd::Intent::run_list(ArgParser* parser) +{ + IntentListRequest request; + request.set_verbosity_level(parser->verbosityLevel()); + + auto on_success = [this](IntentListReply& reply) -> ReturnCodeVariant { + if (reply.intents().empty()) + { + cout << "No intents found.\n"; + return ReturnCode::Ok; + } + + for (const auto& intent : reply.intents()) + { + cout << intent.name() << "\n"; + for (const auto& member : intent.members()) + cout << fmt::format(" {} ({}): {}\n", + member.role(), + member.instance_name(), + instance_status_name(member.instance_status().status())); + } + return ReturnCode::Ok; + }; + auto on_failure = [this](grpc::Status& status, IntentListReply&) -> ReturnCodeVariant { + return standard_failure_handler_for(name(), cerr, status); + }; + + return dispatch(&RpcMethod::intent_list, request, on_success, on_failure); +} + +mp::ReturnCodeVariant cmd::Intent::run_info(ArgParser* parser) +{ + const auto args = parser->positionalArguments(); + if (args.size() < 2) + { + cerr << "Please provide the name of the intent.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + + IntentInfoRequest request; + request.set_name(args[1].toStdString()); + request.set_verbosity_level(parser->verbosityLevel()); + + auto on_success = [this](IntentInfoReply& reply) -> ReturnCodeVariant { + const auto& intent = reply.intent(); + cout << fmt::format("Name: {}\n", intent.name()); + cout << "Members:\n"; + for (const auto& member : intent.members()) + cout << fmt::format(" {} ({}): {}\n", + member.role(), + member.instance_name(), + instance_status_name(member.instance_status().status())); + return ReturnCode::Ok; + }; + auto on_failure = [this](grpc::Status& status, IntentInfoReply&) -> ReturnCodeVariant { + return standard_failure_handler_for(name(), cerr, status); + }; + + return dispatch(&RpcMethod::intent_info, request, on_success, on_failure); +} + +mp::ReturnCodeVariant cmd::Intent::run_delete(ArgParser* parser) +{ + const auto args = parser->positionalArguments(); + if (args.size() < 2) + { + cerr << "Please provide the name of the intent.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + + IntentDeleteRequest request; + request.set_name(args[1].toStdString()); + request.set_purge(parser->isSet(purge_option)); + request.set_verbosity_level(parser->verbosityLevel()); + + auto on_success = [this](IntentDeleteReply& reply) -> ReturnCodeVariant { + cout << reply.reply_message() << "\n"; + return ReturnCode::Ok; + }; + auto on_failure = [this](grpc::Status& status, IntentDeleteReply& reply) -> ReturnCodeVariant { + return standard_failure_handler_for(name(), cerr, status, reply.reply_message()); + }; + + return dispatch(&RpcMethod::intent_delete, request, on_success, on_failure); +} diff --git a/src/client/cli/cmd/intent.h b/src/client/cli/cmd/intent.h new file mode 100644 index 0000000000..770b30a265 --- /dev/null +++ b/src/client/cli/cmd/intent.h @@ -0,0 +1,40 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#pragma once + +#include + +namespace multipass::cmd +{ +class Intent final : public Command +{ +public: + using Command::Command; + ReturnCodeVariant run(ArgParser* parser) override; + + std::string name() const override; + QString short_help() const override; + QString description() const override; + +private: + ReturnCodeVariant run_create(ArgParser* parser); + ReturnCodeVariant run_list(ArgParser* parser); + ReturnCodeVariant run_info(ArgParser* parser); + ReturnCodeVariant run_delete(ArgParser* parser); +}; +} // namespace multipass::cmd diff --git a/src/daemon/CMakeLists.txt b/src/daemon/CMakeLists.txt index 3ad089c956..1290c27ee8 100644 --- a/src/daemon/CMakeLists.txt +++ b/src/daemon/CMakeLists.txt @@ -22,6 +22,7 @@ add_library(daemon STATIC daemon_rpc.cpp default_vm_image_vault.cpp instance_settings_handler.cpp + intent_service_templates.cpp llm_dispatcher.cpp resource_pool.cpp runtime_instance_info_helper.cpp diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index c8b9e5e0c5..719a615cf8 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -18,6 +18,7 @@ #include "daemon.h" #include "base_cloud_init_config.h" #include "instance_settings_handler.h" +#include "intent_service_templates.h" #include "runtime_instance_info_helper.h" #include "snapshot_settings_handler.h" @@ -39,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +72,7 @@ #include +#include #include #include #include @@ -167,6 +170,7 @@ bool is_instance_home(const QString& target, const std::string& username) constexpr auto category = "daemon"; constexpr auto instance_db_name = "multipassd-vm-instances.json"; +constexpr auto intent_db_name = "multipassd-intents.json"; constexpr auto sshfs_error_template = "Error enabling mount support in '{}'" "\n\nPlease install the 'multipass-sshfs' snap manually inside the instance."; @@ -372,6 +376,38 @@ std::unordered_map load_db(const mp::Path& data_path, return reconstructed_records; } +std::unordered_map load_intents_db(const mp::Path& data_path) +{ + QDir data_dir{data_path}; + QFile db_file{data_dir.filePath(intent_db_name)}; + if (!db_file.open(QIODevice::ReadOnly)) + return {}; + + boost::json::value records; + try + { + records = boost::json::parse(std::string_view(db_file.readAll())); + } + catch (const std::runtime_error&) + { + return {}; + } + + std::unordered_map reconstructed_records; + for (const auto& [key, record] : records.as_object()) + { + try + { + reconstructed_records.emplace(key, value_to(record)); + } + catch (const std::exception& e) + { + mpl::warn(category, "Ignoring malformed intent in database: {} ({})", key, e.what()); + } + } + return reconstructed_records; +} + std::string generate_next_clone_name(int clone_count, const std::string& source_name) { return fmt::format("{}-clone{}", source_name, clone_count + 1); @@ -621,6 +657,10 @@ auto connect_rpc(mp::DaemonRpc& rpc, mp::Daemon& daemon, mp::LlmDispatcher* llm_ QObject::connect(&rpc, &mp::DaemonRpc::on_info, &daemon, &mp::Daemon::info); QObject::connect(&rpc, &mp::DaemonRpc::on_list, &daemon, &mp::Daemon::list); QObject::connect(&rpc, &mp::DaemonRpc::on_clone, &daemon, &mp::Daemon::clone); + QObject::connect(&rpc, &mp::DaemonRpc::on_intent_create, &daemon, &mp::Daemon::intent_create); + QObject::connect(&rpc, &mp::DaemonRpc::on_intent_list, &daemon, &mp::Daemon::intent_list); + QObject::connect(&rpc, &mp::DaemonRpc::on_intent_info, &daemon, &mp::Daemon::intent_info); + QObject::connect(&rpc, &mp::DaemonRpc::on_intent_delete, &daemon, &mp::Daemon::intent_delete); QObject::connect(&rpc, &mp::DaemonRpc::on_networks, &daemon, &mp::Daemon::networks); QObject::connect(&rpc, &mp::DaemonRpc::on_mount, &daemon, &mp::Daemon::mount); QObject::connect(&rpc, &mp::DaemonRpc::on_recover, &daemon, &mp::Daemon::recover); @@ -1425,6 +1465,10 @@ mp::Daemon::Daemon(std::unique_ptr the_config) }); connect_rpc(daemon_rpc, *this, llm_dispatcher.get()); + + intents = load_intents_db(mp::utils::backend_directory_path( + config->data_directory, config->factory->get_backend_directory_name())); + std::vector invalid_specs; try @@ -3081,6 +3125,312 @@ catch (const std::exception& e) context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what())); } +namespace +{ +// Bridges a synthetic, internal `launch` invocation (intent_create reuses +// Daemon::create_vm to launch each member) into the real IntentCreateReply +// stream the client actually sees. +class IntentMemberLaunchSink + : public grpc::ServerReaderWriterInterface +{ +public: + explicit IntentMemberLaunchSink( + grpc::ServerReaderWriterInterface* outer) + : outer{outer} + { + } + + void SendInitialMetadata() override + { + } + + bool NextMessageSize(uint32_t* sz) override + { + *sz = 0; + return false; + } + + bool Read(mp::LaunchRequest*) override + { + return false; + } + + bool Write(const mp::LaunchReply& reply, grpc::WriteOptions) override + { + if (!reply.log_line().empty()) + { + mp::IntentCreateReply forwarded; + forwarded.set_log_line(reply.log_line()); + outer->Write(forwarded); + } + return true; + } + +private: + grpc::ServerReaderWriterInterface* outer; +}; + +// A one-shot DaemonRpcContext for a single intent member's internal launch: +// forwards the eventual status to `on_done` instead of fulfilling a promise +// someone blocks on. Nothing in intent_create waits synchronously for this, +// since Daemon::create_vm completes asynchronously (via QFutureWatcher) and +// blocking the daemon's own thread for it would deadlock; the callback is +// what lets intent_create chain the next member without blocking. +// Self-deletes once fired, since it must outlive the (synchronous) call to +// create_vm() but nothing else owns it afterwards. +class IntentMemberContext : public mp::DaemonRpcContext +{ +public: + explicit IntentMemberContext(std::function on_done) + : on_done{std::move(on_done)} + { + } + + void set_value(grpc::Status status) override + { + auto callback = std::move(on_done); + delete this; + callback(std::move(status)); + } + +private: + std::function on_done; +}; + +void set_timestamp_from_iso8601(google::protobuf::Timestamp* timestamp, const std::string& iso8601) +{ + auto date_time = QDateTime::fromString(QString::fromStdString(iso8601), Qt::ISODateWithMs); + timestamp->set_seconds(date_time.toSecsSinceEpoch()); + timestamp->set_nanos(date_time.time().msec() * 1'000'000); +} +} // namespace + +void mp::Daemon::intent_create( + const IntentCreateRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + const auto& name = request->name(); + if (name.empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Intent name cannot be empty", ""}); + if (intents.count(name)) + return context->set_value({grpc::StatusCode::INVALID_ARGUMENT, + fmt::format("Intent \"{}\" already exists", name), + ""}); + if (request->members().empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "An intent needs at least one member", ""}); + + auto launch_requests = std::make_shared>(); + for (const auto& member : request->members()) + { + const auto& role = member.role(); + if (role.empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Each intent member needs a role", ""}); + + std::string image = member.image(); + std::string cloud_init = member.cloud_init_user_data(); + if (image.empty() && cloud_init.empty()) + { + auto tmpl = find_intent_service_template(role); + if (!tmpl) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, + fmt::format("Unknown service template \"{}\"; pass an image and/or " + "cloud-init file for a custom member", + role), + ""}); + image = tmpl->image; + cloud_init = tmpl->cloud_init_user_data; + } + + LaunchRequest lr; + lr.set_instance_name(fmt::format("{}-{}", name, role)); + lr.set_image(image); + lr.set_cloud_init_user_data(cloud_init); + lr.set_num_cores(member.num_cores() > 0 ? member.num_cores() : 1); + lr.set_mem_size(member.mem_size().empty() ? "1G" : member.mem_size()); + lr.set_disk_space(member.disk_space().empty() ? "5G" : member.disk_space()); + lr.set_intent(name); + lr.set_intent_role(role); + + launch_requests->push_back(std::move(lr)); + } + + auto sink = std::make_shared(server); + auto spec = std::make_shared(); + spec->name = name; + spec->creation_timestamp = + QDateTime::currentDateTime().toString(Qt::ISODateWithMs).toStdString(); + + auto member_index = std::make_shared(0); + auto launch_next = std::make_shared>(); + *launch_next = [this, server, context, launch_requests, sink, spec, member_index, launch_next] { + if (*member_index >= launch_requests->size()) + { + intents[spec->name] = *spec; + persist_intents(); + + IntentCreateReply reply; + reply.set_reply_message(fmt::format("Intent \"{}\" created with {} member(s).", + spec->name, + spec->members.size())); + server->Write(reply); + return context->set_value(grpc::Status::OK); + } + + auto& member_request = (*launch_requests)[*member_index]; + auto role = member_request.intent_role(); + + auto* member_context = new IntentMemberContext( + [launch_requests, spec, member_index, launch_next, context, role]( + grpc::Status status) { + if (!status.ok()) + // Members already launched are left running (not rolled back in v1). + return context->set_value(status); + + spec->members.push_back({role, (*launch_requests)[*member_index].instance_name()}); + ++(*member_index); + (*launch_next)(); + }); + + try + { + create_vm(&member_request, sink.get(), member_context, /*start=*/true); + } + catch (const std::exception& e) + { + delete member_context; // create_vm threw before it could hand off ownership + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); + } + }; + + (*launch_next)(); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + +void mp::Daemon::intent_list( + const IntentListRequest*, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + IntentListReply response; + + for (const auto& [intent_name, spec] : intents) + { + auto* info = response.add_intents(); + info->set_name(intent_name); + set_timestamp_from_iso8601(info->mutable_creation_timestamp(), spec.creation_timestamp); + + for (const auto& member : spec.members) + { + auto* proto_member = info->add_members(); + proto_member->set_role(member.role); + proto_member->set_instance_name(member.instance_name); + + auto vm_it = operative_instances.find(member.instance_name); + proto_member->mutable_instance_status()->set_status( + vm_it == operative_instances.end() + ? mp::InstanceStatus::DELETED + : grpc_instance_status_for(vm_it->second->current_state())); + } + } + + server->Write(response); + context->set_value(grpc::Status::OK); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + +void mp::Daemon::intent_info( + const IntentInfoRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + auto it = intents.find(request->name()); + if (it == intents.end()) + return context->set_value( + {grpc::StatusCode::NOT_FOUND, + fmt::format("Intent \"{}\" does not exist", request->name()), + ""}); + + const auto& spec = it->second; + IntentInfoReply response; + auto* info = response.mutable_intent(); + info->set_name(spec.name); + set_timestamp_from_iso8601(info->mutable_creation_timestamp(), spec.creation_timestamp); + + for (const auto& member : spec.members) + { + auto* proto_member = info->add_members(); + proto_member->set_role(member.role); + proto_member->set_instance_name(member.instance_name); + + auto vm_it = operative_instances.find(member.instance_name); + proto_member->mutable_instance_status()->set_status( + vm_it == operative_instances.end() ? mp::InstanceStatus::DELETED + : grpc_instance_status_for(vm_it->second->current_state())); + } + + server->Write(response); + context->set_value(grpc::Status::OK); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + +void mp::Daemon::intent_delete( + const IntentDeleteRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + auto it = intents.find(request->name()); + if (it == intents.end()) + return context->set_value( + {grpc::StatusCode::NOT_FOUND, + fmt::format("Intent \"{}\" does not exist", request->name()), + ""}); + + const auto purge = request->purge(); + auto instances_dirty = false; + for (const auto& member : it->second.members) + { + auto vm_it = operative_instances.find(member.instance_name); + if (vm_it == operative_instances.end()) + continue; + + DeleteReply throwaway_response; + instances_dirty |= delete_vm(vm_it, purge, throwaway_response); + } + + if (instances_dirty) + persist_instances(); + + intents.erase(it); + persist_intents(); + + IntentDeleteReply response; + response.set_reply_message(fmt::format("Intent \"{}\" deleted", request->name())); + server->Write(response); + context->set_value(grpc::Status::OK); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + void mp::Daemon::daemon_info( const DaemonInfoRequest*, grpc::ServerReaderWriterInterface* server, @@ -3378,6 +3728,15 @@ void mp::Daemon::persist_instances() pretty_print(instance_records_json)); } +void mp::Daemon::persist_intents() +{ + auto intent_records_json = boost::json::value_from(intents); + QDir data_dir{mp::utils::backend_directory_path(config->data_directory, + config->factory->get_backend_directory_name())}; + MP_FILEOPS.write_transactionally(data_dir.filePath(intent_db_name), + pretty_print(intent_records_json)); +} + void mp::Daemon::release_resources(const std::string& instance) { release_vm_claim(instance); diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index 2a95db0cf7..49057ab5bd 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +55,7 @@ class Daemon : public QObject, public multipass::VMStatusMonitor ~Daemon(); void persist_instances(); + void persist_intents(); protected: using InstanceTable = std::unordered_map; @@ -157,6 +159,26 @@ public slots: grpc::ServerReaderWriterInterface* server, DaemonRpcContext* context); + virtual void intent_create( + const IntentCreateRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + + virtual void intent_list( + const IntentListRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + + virtual void intent_info( + const IntentInfoRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + + virtual void intent_delete( + const IntentDeleteRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + virtual void snapshot(const SnapshotRequest* request, grpc::ServerReaderWriterInterface* server, DaemonRpcContext* context); @@ -281,6 +303,7 @@ public slots: protected: std::unordered_map vm_instance_specs; + std::unordered_map intents; InstanceTable operative_instances; bool is_bridged(const std::string& instance_name) const; diff --git a/src/daemon/daemon_rpc.cpp b/src/daemon/daemon_rpc.cpp index ac1cd038d3..33e042f0de 100644 --- a/src/daemon/daemon_rpc.cpp +++ b/src/daemon/daemon_rpc.cpp @@ -252,6 +252,58 @@ grpc::Status mp::DaemonRpc::clone(grpc::ServerContext* context, server); } +grpc::Status mp::DaemonRpc::intent_create( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_intent_create, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + +grpc::Status mp::DaemonRpc::intent_list( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_intent_list, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + +grpc::Status mp::DaemonRpc::intent_info( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_intent_info, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + +grpc::Status mp::DaemonRpc::intent_delete( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_intent_delete, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + grpc::Status mp::DaemonRpc::networks( grpc::ServerContext* context, grpc::ServerReaderWriter* server) diff --git a/src/daemon/daemon_rpc.h b/src/daemon/daemon_rpc.h index e61f905984..1ac1eea2b2 100644 --- a/src/daemon/daemon_rpc.h +++ b/src/daemon/daemon_rpc.h @@ -83,6 +83,18 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl void on_clone(const CloneRequest* request, grpc::ServerReaderWriter* server, DaemonRpcContext* context); + void on_intent_create(const IntentCreateRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); + void on_intent_list(const IntentListRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); + void on_intent_info(const IntentInfoRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); + void on_intent_delete(const IntentDeleteRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); void on_networks(const NetworksRequest* request, grpc::ServerReaderWriter* server, DaemonRpcContext* context); @@ -229,6 +241,18 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl grpc::ServerReaderWriter* server) override; grpc::Status clone(grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; + grpc::Status intent_create( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; + grpc::Status intent_list( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; + grpc::Status intent_info( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; + grpc::Status intent_delete( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; grpc::Status networks( grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; diff --git a/src/daemon/intent_service_templates.cpp b/src/daemon/intent_service_templates.cpp new file mode 100644 index 0000000000..7564a730d4 --- /dev/null +++ b/src/daemon/intent_service_templates.cpp @@ -0,0 +1,56 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include "intent_service_templates.h" + +namespace mp = multipass; + +namespace +{ +constexpr auto redis_cloud_init = R"YAML(packages: +- redis-server + +runcmd: +- | + sed -i 's/^bind 127.0.0.1.*/bind 0.0.0.0/' /etc/redis/redis.conf + systemctl enable redis-server --now + +final_message: "redis is up, after $UPTIME seconds" +)YAML"; + +constexpr auto postgres_cloud_init = R"YAML(packages: +- postgresql + +runcmd: +- | + echo "listen_addresses = '*'" >> /etc/postgresql/*/main/postgresql.conf + echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/*/main/pg_hba.conf + systemctl enable postgresql --now + systemctl restart postgresql + +final_message: "postgres is up, after $UPTIME seconds" +)YAML"; +} // namespace + +std::optional mp::find_intent_service_template(const std::string& role) +{ + if (role == "redis") + return IntentServiceTemplate{{}, redis_cloud_init}; + if (role == "postgres") + return IntentServiceTemplate{{}, postgres_cloud_init}; + return std::nullopt; +} diff --git a/src/daemon/intent_service_templates.h b/src/daemon/intent_service_templates.h new file mode 100644 index 0000000000..53b04886e5 --- /dev/null +++ b/src/daemon/intent_service_templates.h @@ -0,0 +1,38 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#pragma once + +#include +#include + +namespace multipass +{ + +struct IntentServiceTemplate +{ + std::string image; + std::string cloud_init_user_data; +}; + +// Named service templates for `elp intent create --service ` members +// that don't specify their own image/cloud-init. See +// data/cloud-init-yaml/cloud-init-.yaml for the reference copy of each +// template's cloud-init content (kept in sync manually). +std::optional find_intent_service_template(const std::string& role); + +} // namespace multipass diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index ae90b3e026..fd87813cf6 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -44,6 +44,10 @@ service Rpc { rpc snapshot (stream SnapshotRequest) returns (stream SnapshotReply); rpc restore (stream RestoreRequest) returns (stream RestoreReply); rpc clone (stream CloneRequest) returns (stream CloneReply); + rpc intent_create (stream IntentCreateRequest) returns (stream IntentCreateReply); + rpc intent_list (stream IntentListRequest) returns (stream IntentListReply); + rpc intent_info (stream IntentInfoRequest) returns (stream IntentInfoReply); + rpc intent_delete (stream IntentDeleteRequest) returns (stream IntentDeleteReply); rpc daemon_info (stream DaemonInfoRequest) returns (stream DaemonInfoReply); rpc wait_ready (stream WaitReadyRequest) returns (stream WaitReadyReply); rpc zones (stream ZonesRequest) returns (stream ZonesReply); @@ -96,6 +100,8 @@ message LaunchRequest { string password = 15; string zone = 16; string service_id = 17; + string intent = 18; // name of the intent this instance belongs to, if any + string intent_role = 19; // this instance's role within that intent (e.g. "redis") } message LaunchError { @@ -274,6 +280,8 @@ message DetailedInfoItem { Zone zone = 9; string service_id = 10; + string intent = 11; + string intent_role = 12; } message InfoReply { @@ -566,6 +574,75 @@ message CloneReply { string reply_message = 1; string log_line = 2; } + +// A single member to launch as part of an intent. Either set `role` to the +// name of a known service template (e.g. "redis", "postgres") or fill in +// `image`/`cloud_init_user_data` directly for an inline, ad-hoc member. +// cloud_init_user_data is raw cloud-init content, not a path — same +// convention as LaunchRequest, so this works against a remote daemon too; +// the CLI reads and dumps any --instance cloud-init file client-side. +message IntentMemberRequest { + string role = 1; + string image = 2; + string cloud_init_user_data = 3; + int32 num_cores = 4; + string mem_size = 5; + string disk_space = 6; +} + +message IntentCreateRequest { + string name = 1; + repeated IntentMemberRequest members = 2; + int32 verbosity_level = 3; +} + +message IntentCreateReply { + string log_line = 1; + string reply_message = 2; +} + +message IntentMember { + string role = 1; + string instance_name = 2; + InstanceStatus instance_status = 3; +} + +message IntentInfo { + string name = 1; + repeated IntentMember members = 2; + google.protobuf.Timestamp creation_timestamp = 3; +} + +message IntentListRequest { + int32 verbosity_level = 1; +} + +message IntentListReply { + repeated IntentInfo intents = 1; + string log_line = 2; +} + +message IntentInfoRequest { + string name = 1; + int32 verbosity_level = 2; +} + +message IntentInfoReply { + IntentInfo intent = 1; + string log_line = 2; +} + +message IntentDeleteRequest { + string name = 1; + bool purge = 2; + int32 verbosity_level = 3; +} + +message IntentDeleteReply { + string log_line = 1; + string reply_message = 2; +} + message DaemonInfoRequest { int32 verbosity_level = 1; } diff --git a/src/utils/CMakeLists.txt b/src/utils/CMakeLists.txt index e88dcf67d1..d8c29fd37e 100644 --- a/src/utils/CMakeLists.txt +++ b/src/utils/CMakeLists.txt @@ -16,6 +16,7 @@ function(add_target TARGET_NAME) add_library(${TARGET_NAME} STATIC alias_definition.cpp file_ops.cpp + intent_spec.cpp memory_size.cpp permission_utils.cpp json_utils.cpp diff --git a/src/utils/intent_spec.cpp b/src/utils/intent_spec.cpp new file mode 100644 index 0000000000..5f05a79128 --- /dev/null +++ b/src/utils/intent_spec.cpp @@ -0,0 +1,53 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include +#include + +namespace mp = multipass; + +void mp::tag_invoke(const boost::json::value_from_tag&, + boost::json::value& json, + const mp::IntentSpec& spec) +{ + boost::json::array members; + for (const auto& member : spec.members) + members.push_back({ + {"role", member.role}, + {"instance_name", member.instance_name}, + }); + + json = { + {"name", spec.name}, + {"members", members}, + {"creation_timestamp", spec.creation_timestamp}, + }; +} + +mp::IntentSpec mp::tag_invoke(const boost::json::value_to_tag&, + const boost::json::value& json) +{ + IntentSpec spec; + spec.name = value_to(json.at("name")); + spec.creation_timestamp = lookup_or(json, "creation_timestamp", {}); + + for (const auto& member : json.at("members").as_array()) + spec.members.push_back({value_to(member.at("role")), + value_to(member.at("instance_name"))}); + + return spec; +} From f7e5588a9920a36983390f19a9b7414554d306f6 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 16:01:06 +0200 Subject: [PATCH 15/27] Actually populate intent/intent_role metadata (was a no-op until now) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intent_create handler set intent/intent_role on each member's internal LaunchRequest, and DetailedInfoItem gained proto fields for them, but create_vm's completion handler only ever captured request->service_id() into VMSpecs.metadata — intent/intent_role were silently dropped, and populate_instance_info never read them back out either. Wire both sides: capture intent/intent_role into metadata alongside service_id, and surface them on `elp info` via a small metadata_string() helper next to the existing service_id_from_specs(). --- src/daemon/daemon.cpp | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index 719a615cf8..9b4b5feff3 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -479,6 +479,13 @@ std::string service_id_from_specs(const mp::VMSpecs& specs) return {}; } +std::string metadata_string(const mp::VMSpecs& specs, const char* key) +{ + if (auto it = specs.metadata.find(key); it != specs.metadata.end() && it->value().is_string()) + return std::string(it->value().as_string()); + return {}; +} + std::vector validate_extra_interfaces( const mp::LaunchRequest* request, @@ -3814,7 +3821,16 @@ void mp::Daemon::create_vm(const CreateRequest* request, QObject::connect(prepare_future_watcher, &QFutureWatcher::finished, - [this, server, context, name, timeout, start, prepare_future_watcher, service_id = std::string{request->service_id()}] { + [this, + server, + context, + name, + timeout, + start, + prepare_future_watcher, + service_id = std::string{request->service_id()}, + intent = std::string{request->intent()}, + intent_role = std::string{request->intent_role()}] { // Per-RPC ClientLogger lifecycle is managed by DaemonRpcContextImpl. try @@ -3824,6 +3840,11 @@ void mp::Daemon::create_vm(const CreateRequest* request, boost::json::object meta; if (!service_id.empty()) meta["elemento_service_id"] = service_id; + if (!intent.empty()) + { + meta["intent"] = intent; + meta["intent_role"] = intent_role; + } vm_instance_specs[name] = { vm_desc.num_cores, @@ -4548,6 +4569,12 @@ void mp::Daemon::populate_instance_info(VirtualMachine& vm, if (const auto sid = service_id_from_specs(vm_specs); !sid.empty()) info->set_service_id(sid); + if (const auto intent = metadata_string(vm_specs, "intent"); !intent.empty()) + { + info->set_intent(intent); + info->set_intent_role(metadata_string(vm_specs, "intent_role")); + } + auto mount_info = info->mutable_mount_info(); populate_mount_info(vm_specs.mounts, mount_info, have_mounts); From db611c9c8502268a3c0094c145498c401db605fa Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 16:07:10 +0200 Subject: [PATCH 16/27] Add "elp intent add" to add members to an existing intent intent_create previously rejected an already-existing name outright, with no way to grow a running intent (e.g. add a cache to an app you already stood up). Adds a new intent_add_member RPC and `elp intent add --service ` (same --instance inline mode as create). Factors the async per-member launch chaining out of intent_create into a shared Daemon::launch_intent_members(), so intent_add_member reuses it rather than duplicating the tricky (and only recently validated) chaining logic. Also guards against a narrow race: since the chain is asynchronous, a concurrent intent_delete could remove the intent while members are still being launched; intent_add_member's completion handler checks the registry still has the entry instead of reviving it via intents[name]. --- src/client/cli/cmd/intent.cpp | 101 ++++++++++++---- src/client/cli/cmd/intent.h | 6 + src/daemon/daemon.cpp | 216 +++++++++++++++++++++++++--------- src/daemon/daemon.h | 17 +++ src/daemon/daemon_rpc.cpp | 13 ++ src/daemon/daemon_rpc.h | 7 ++ src/rpc/multipass.proto | 12 ++ 7 files changed, 290 insertions(+), 82 deletions(-) diff --git a/src/client/cli/cmd/intent.cpp b/src/client/cli/cmd/intent.cpp index 949dd4b909..d135722a87 100644 --- a/src/client/cli/cmd/intent.cpp +++ b/src/client/cli/cmd/intent.cpp @@ -64,7 +64,7 @@ std::string instance_status_name(mp::InstanceStatus::Status status) mp::ReturnCodeVariant cmd::Intent::run(ArgParser* parser) { - parser->addPositionalArgument("action", "create | list | info | delete", ""); + parser->addPositionalArgument("action", "create | add | list | info | delete", ""); parser->addPositionalArgument("name", "The intent's name (not needed for `list`)", "[]"); parser->addOption(service_option); parser->addOption(instance_option); @@ -77,13 +77,15 @@ mp::ReturnCodeVariant cmd::Intent::run(ArgParser* parser) const auto args = parser->positionalArguments(); if (args.empty()) { - cerr << "Please specify an action: create, list, info, or delete.\n"; + cerr << "Please specify an action: create, add, list, info, or delete.\n"; return parser->returnCodeFrom(ParseCode::CommandLineError); } const auto action = args[0].toStdString(); if (action == "create") return run_create(parser); + if (action == "add") + return run_add(parser); if (action == "list") return run_list(parser); if (action == "info") @@ -91,7 +93,8 @@ mp::ReturnCodeVariant cmd::Intent::run(ArgParser* parser) if (action == "delete") return run_delete(parser); - cerr << fmt::format("Unknown action \"{}\"; expected create, list, info, or delete.\n", action); + cerr << fmt::format( + "Unknown action \"{}\"; expected create, add, list, info, or delete.\n", action); return parser->returnCodeFrom(ParseCode::CommandLineError); } @@ -109,49 +112,36 @@ QString cmd::Intent::description() const { return QStringLiteral( "Create a named group of instances launched together (e.g. a \"redis\" and a " - "\"postgres\" instance for an app), and list, inspect, or delete such groups.\n\n" + "\"postgres\" instance for an app), and add to, list, inspect, or delete such " + "groups.\n\n" " elp intent create --service redis --service postgres\n" + " elp intent add --service redis\n" " elp intent list\n" " elp intent info \n" " elp intent delete [--purge]"); } -mp::ReturnCodeVariant cmd::Intent::run_create(ArgParser* parser) +bool cmd::Intent::parse_members(ArgParser* parser, + google::protobuf::RepeatedPtrField* members) { - const auto args = parser->positionalArguments(); - if (args.size() < 2) - { - cerr << "Please provide a name for the intent.\n"; - return parser->returnCodeFrom(ParseCode::CommandLineError); - } - if (!parser->isSet(service_option) && !parser->isSet(instance_option)) - { - cerr << "Please specify at least one member with --service or --instance.\n"; - return parser->returnCodeFrom(ParseCode::CommandLineError); - } - - IntentCreateRequest request; - request.set_name(args[1].toStdString()); - request.set_verbosity_level(parser->verbosityLevel()); - for (const auto& role : parser->values(service_option)) { - auto* member = request.add_members(); + auto* member = members->Add(); member->set_role(role.toStdString()); } for (const auto& spec : parser->values(instance_option)) { const auto fields = spec.split(':'); - if (fields.size() < 1 || fields[0].isEmpty()) + if (fields[0].isEmpty()) { cerr << fmt::format("Invalid --instance spec \"{}\"; expected " "role:image:cloud-init-file:cores:mem:disk.\n", spec.toStdString()); - return parser->returnCodeFrom(ParseCode::CommandLineError); + return false; } - auto* member = request.add_members(); + auto* member = members->Add(); member->set_role(fields[0].toStdString()); if (fields.size() > 1 && !fields[1].isEmpty()) member->set_image(fields[1].toStdString()); @@ -162,7 +152,7 @@ mp::ReturnCodeVariant cmd::Intent::run_create(ArgParser* parser) { cerr << fmt::format("Could not read cloud-init file \"{}\".\n", fields[2].toStdString()); - return parser->returnCodeFrom(ParseCode::CommandLineError); + return false; } member->set_cloud_init_user_data(QTextStream{&file}.readAll().toStdString()); } @@ -174,6 +164,29 @@ mp::ReturnCodeVariant cmd::Intent::run_create(ArgParser* parser) member->set_disk_space(fields[5].toStdString()); } + return true; +} + +mp::ReturnCodeVariant cmd::Intent::run_create(ArgParser* parser) +{ + const auto args = parser->positionalArguments(); + if (args.size() < 2) + { + cerr << "Please provide a name for the intent.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + if (!parser->isSet(service_option) && !parser->isSet(instance_option)) + { + cerr << "Please specify at least one member with --service or --instance.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + + IntentCreateRequest request; + request.set_name(args[1].toStdString()); + request.set_verbosity_level(parser->verbosityLevel()); + if (!parse_members(parser, request.mutable_members())) + return parser->returnCodeFrom(ParseCode::CommandLineError); + AnimatedSpinner spinner{cout}; auto on_success = [this, &spinner](IntentCreateReply& reply) -> ReturnCodeVariant { spinner.stop(); @@ -190,6 +203,42 @@ mp::ReturnCodeVariant cmd::Intent::run_create(ArgParser* parser) return dispatch(&RpcMethod::intent_create, request, on_success, on_failure); } +mp::ReturnCodeVariant cmd::Intent::run_add(ArgParser* parser) +{ + const auto args = parser->positionalArguments(); + if (args.size() < 2) + { + cerr << "Please provide the name of the intent to add to.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + if (!parser->isSet(service_option) && !parser->isSet(instance_option)) + { + cerr << "Please specify at least one member with --service or --instance.\n"; + return parser->returnCodeFrom(ParseCode::CommandLineError); + } + + IntentAddMemberRequest request; + request.set_name(args[1].toStdString()); + request.set_verbosity_level(parser->verbosityLevel()); + if (!parse_members(parser, request.mutable_members())) + return parser->returnCodeFrom(ParseCode::CommandLineError); + + AnimatedSpinner spinner{cout}; + auto on_success = [this, &spinner](IntentAddMemberReply& reply) -> ReturnCodeVariant { + spinner.stop(); + cout << reply.reply_message() << "\n"; + return ReturnCode::Ok; + }; + auto on_failure = [this, &spinner](grpc::Status& status, + IntentAddMemberReply& reply) -> ReturnCodeVariant { + spinner.stop(); + return standard_failure_handler_for(name(), cerr, status, reply.reply_message()); + }; + + spinner.start("Adding to intent " + request.name()); + return dispatch(&RpcMethod::intent_add_member, request, on_success, on_failure); +} + mp::ReturnCodeVariant cmd::Intent::run_list(ArgParser* parser) { IntentListRequest request; diff --git a/src/client/cli/cmd/intent.h b/src/client/cli/cmd/intent.h index 770b30a265..ac2f6b97b6 100644 --- a/src/client/cli/cmd/intent.h +++ b/src/client/cli/cmd/intent.h @@ -33,8 +33,14 @@ class Intent final : public Command private: ReturnCodeVariant run_create(ArgParser* parser); + ReturnCodeVariant run_add(ArgParser* parser); ReturnCodeVariant run_list(ArgParser* parser); ReturnCodeVariant run_info(ArgParser* parser); ReturnCodeVariant run_delete(ArgParser* parser); + + // Parses --service/--instance into `members`; returns false (and prints an error) on a + // malformed --instance spec. Shared by run_create and run_add. + bool parse_members(ArgParser* parser, + google::protobuf::RepeatedPtrField* members); }; } // namespace multipass::cmd diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index 9b4b5feff3..78a905194d 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -665,6 +665,10 @@ auto connect_rpc(mp::DaemonRpc& rpc, mp::Daemon& daemon, mp::LlmDispatcher* llm_ QObject::connect(&rpc, &mp::DaemonRpc::on_list, &daemon, &mp::Daemon::list); QObject::connect(&rpc, &mp::DaemonRpc::on_clone, &daemon, &mp::Daemon::clone); QObject::connect(&rpc, &mp::DaemonRpc::on_intent_create, &daemon, &mp::Daemon::intent_create); + QObject::connect(&rpc, + &mp::DaemonRpc::on_intent_add_member, + &daemon, + &mp::Daemon::intent_add_member); QObject::connect(&rpc, &mp::DaemonRpc::on_intent_list, &daemon, &mp::Daemon::intent_list); QObject::connect(&rpc, &mp::DaemonRpc::on_intent_info, &daemon, &mp::Daemon::intent_info); QObject::connect(&rpc, &mp::DaemonRpc::on_intent_delete, &daemon, &mp::Daemon::intent_delete); @@ -3210,113 +3214,213 @@ void set_timestamp_from_iso8601(google::protobuf::Timestamp* timestamp, const st timestamp->set_seconds(date_time.toSecsSinceEpoch()); timestamp->set_nanos(date_time.time().msec() * 1'000'000); } -} // namespace -void mp::Daemon::intent_create( - const IntentCreateRequest* request, - grpc::ServerReaderWriterInterface* server, - DaemonRpcContext* context) -try +// Validates and builds one internal LaunchRequest per requested intent member (resolving +// named service templates, or using the caller's inline image/cloud-init). Shared by +// intent_create and intent_add_member. On error, returns std::nullopt and sets `error`. +std::optional> build_intent_member_launch_requests( + const std::string& intent_name, + const google::protobuf::RepeatedPtrField& members, + grpc::Status& error) { - const auto& name = request->name(); - if (name.empty()) - return context->set_value( - {grpc::StatusCode::INVALID_ARGUMENT, "Intent name cannot be empty", ""}); - if (intents.count(name)) - return context->set_value({grpc::StatusCode::INVALID_ARGUMENT, - fmt::format("Intent \"{}\" already exists", name), - ""}); - if (request->members().empty()) - return context->set_value( - {grpc::StatusCode::INVALID_ARGUMENT, "An intent needs at least one member", ""}); - - auto launch_requests = std::make_shared>(); - for (const auto& member : request->members()) + std::vector launch_requests; + for (const auto& member : members) { const auto& role = member.role(); if (role.empty()) - return context->set_value( - {grpc::StatusCode::INVALID_ARGUMENT, "Each intent member needs a role", ""}); + { + error = {grpc::StatusCode::INVALID_ARGUMENT, "Each intent member needs a role", ""}; + return std::nullopt; + } std::string image = member.image(); std::string cloud_init = member.cloud_init_user_data(); if (image.empty() && cloud_init.empty()) { - auto tmpl = find_intent_service_template(role); + auto tmpl = mp::find_intent_service_template(role); if (!tmpl) - return context->set_value( - {grpc::StatusCode::INVALID_ARGUMENT, - fmt::format("Unknown service template \"{}\"; pass an image and/or " - "cloud-init file for a custom member", - role), - ""}); + { + error = {grpc::StatusCode::INVALID_ARGUMENT, + fmt::format("Unknown service template \"{}\"; pass an image and/or " + "cloud-init file for a custom member", + role), + ""}; + return std::nullopt; + } image = tmpl->image; cloud_init = tmpl->cloud_init_user_data; } - LaunchRequest lr; - lr.set_instance_name(fmt::format("{}-{}", name, role)); + mp::LaunchRequest lr; + lr.set_instance_name(fmt::format("{}-{}", intent_name, role)); lr.set_image(image); lr.set_cloud_init_user_data(cloud_init); lr.set_num_cores(member.num_cores() > 0 ? member.num_cores() : 1); lr.set_mem_size(member.mem_size().empty() ? "1G" : member.mem_size()); lr.set_disk_space(member.disk_space().empty() ? "5G" : member.disk_space()); - lr.set_intent(name); + lr.set_intent(intent_name); lr.set_intent_role(role); - launch_requests->push_back(std::move(lr)); + launch_requests.push_back(std::move(lr)); } + return launch_requests; +} +} // namespace - auto sink = std::make_shared(server); - auto spec = std::make_shared(); - spec->name = name; - spec->creation_timestamp = - QDateTime::currentDateTime().toString(Qt::ISODateWithMs).toStdString(); - +void mp::Daemon::launch_intent_members( + std::shared_ptr> launch_requests, + std::shared_ptr> member_sink, + std::function)> on_all_launched, + std::function on_failure) +{ + auto launched = std::make_shared>(); auto member_index = std::make_shared(0); auto launch_next = std::make_shared>(); - *launch_next = [this, server, context, launch_requests, sink, spec, member_index, launch_next] { + *launch_next = [this, + launch_requests, + member_sink, + launched, + member_index, + launch_next, + on_all_launched, + on_failure] { if (*member_index >= launch_requests->size()) - { - intents[spec->name] = *spec; - persist_intents(); - - IntentCreateReply reply; - reply.set_reply_message(fmt::format("Intent \"{}\" created with {} member(s).", - spec->name, - spec->members.size())); - server->Write(reply); - return context->set_value(grpc::Status::OK); - } + return on_all_launched(*launched); auto& member_request = (*launch_requests)[*member_index]; auto role = member_request.intent_role(); auto* member_context = new IntentMemberContext( - [launch_requests, spec, member_index, launch_next, context, role]( + [launch_requests, launched, member_index, launch_next, on_failure, role]( grpc::Status status) { if (!status.ok()) // Members already launched are left running (not rolled back in v1). - return context->set_value(status); + return on_failure(status); - spec->members.push_back({role, (*launch_requests)[*member_index].instance_name()}); + launched->push_back({role, (*launch_requests)[*member_index].instance_name()}); ++(*member_index); (*launch_next)(); }); try { - create_vm(&member_request, sink.get(), member_context, /*start=*/true); + create_vm(&member_request, member_sink.get(), member_context, /*start=*/true); } catch (const std::exception& e) { delete member_context; // create_vm threw before it could hand off ownership - context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); + on_failure(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); } }; (*launch_next)(); } + +void mp::Daemon::intent_create( + const IntentCreateRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + const auto& name = request->name(); + if (name.empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Intent name cannot be empty", ""}); + if (intents.count(name)) + return context->set_value({grpc::StatusCode::INVALID_ARGUMENT, + fmt::format("Intent \"{}\" already exists", name), + ""}); + if (request->members().empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "An intent needs at least one member", ""}); + + grpc::Status build_error; + auto built = build_intent_member_launch_requests(name, request->members(), build_error); + if (!built) + return context->set_value(build_error); + + auto launch_requests = std::make_shared>(std::move(*built)); + std::shared_ptr> sink = + std::make_shared(server); + + launch_intent_members( + launch_requests, + sink, + [this, server, context, name](std::vector members) { + IntentSpec spec; + spec.name = name; + spec.members = std::move(members); + spec.creation_timestamp = + QDateTime::currentDateTime().toString(Qt::ISODateWithMs).toStdString(); + intents[name] = spec; + persist_intents(); + + IntentCreateReply reply; + reply.set_reply_message(fmt::format("Intent \"{}\" created with {} member(s).", + name, + spec.members.size())); + server->Write(reply); + context->set_value(grpc::Status::OK); + }, + [context](grpc::Status status) { context->set_value(status); }); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + +void mp::Daemon::intent_add_member( + const IntentAddMemberRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + const auto& name = request->name(); + if (!intents.count(name)) + return context->set_value( + {grpc::StatusCode::NOT_FOUND, fmt::format("Intent \"{}\" does not exist", name), ""}); + if (request->members().empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Provide at least one member to add", ""}); + + grpc::Status build_error; + auto built = build_intent_member_launch_requests(name, request->members(), build_error); + if (!built) + return context->set_value(build_error); + + auto launch_requests = std::make_shared>(std::move(*built)); + std::shared_ptr> sink = + std::make_shared(server); + + launch_intent_members( + launch_requests, + sink, + [this, server, context, name](std::vector members) { + // launch_intent_members runs asynchronously; guard against the intent having + // been deleted (by a concurrent intent_delete) while these members were still + // being launched, rather than silently reviving it with intents[name]. + auto it = intents.find(name); + if (it == intents.end()) + return context->set_value( + {grpc::StatusCode::ABORTED, + fmt::format("Intent \"{}\" was deleted while members were being added; " + "the new instance(s) are still running but untracked", + name), + ""}); + + const auto added = members.size(); + for (auto& member : members) + it->second.members.push_back(std::move(member)); + persist_intents(); + + IntentAddMemberReply reply; + reply.set_reply_message( + fmt::format("Added {} member(s) to intent \"{}\".", added, name)); + server->Write(reply); + context->set_value(grpc::Status::OK); + }, + [context](grpc::Status status) { context->set_value(status); }); +} catch (const std::exception& e) { context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index 49057ab5bd..3920c7aa37 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -164,6 +164,11 @@ public slots: grpc::ServerReaderWriterInterface* server, DaemonRpcContext* context); + virtual void intent_add_member( + const IntentAddMemberRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + virtual void intent_list( const IntentListRequest* request, grpc::ServerReaderWriterInterface* server, @@ -222,6 +227,18 @@ public slots: grpc::ServerReaderWriterInterface* server, DaemonRpcContext* context, bool start); + + // Launches each of launch_requests via create_vm, one at a time (create_vm is itself + // asynchronous, so this chains rather than blocking); once all have succeeded, + // on_all_launched is called with the resulting {role, instance_name} pairs (same order), + // or on_failure is called on the first member that fails (earlier members stay launched). + // Shared by intent_create and intent_add_member so the async chaining logic isn't + // duplicated between them. + void launch_intent_members( + std::shared_ptr> launch_requests, + std::shared_ptr> member_sink, + std::function)> on_all_launched, + std::function on_failure); bool delete_vm(InstanceTable::iterator vm_it, bool purge, DeleteReply& response); grpc::Status reboot_vm(VirtualMachine& vm); grpc::Status shutdown_vm(VirtualMachine& vm, const std::chrono::milliseconds delay); diff --git a/src/daemon/daemon_rpc.cpp b/src/daemon/daemon_rpc.cpp index 33e042f0de..873f117a4b 100644 --- a/src/daemon/daemon_rpc.cpp +++ b/src/daemon/daemon_rpc.cpp @@ -265,6 +265,19 @@ grpc::Status mp::DaemonRpc::intent_create( server); } +grpc::Status mp::DaemonRpc::intent_add_member( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_intent_add_member, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + grpc::Status mp::DaemonRpc::intent_list( grpc::ServerContext* context, grpc::ServerReaderWriter* server) diff --git a/src/daemon/daemon_rpc.h b/src/daemon/daemon_rpc.h index 1ac1eea2b2..957db01709 100644 --- a/src/daemon/daemon_rpc.h +++ b/src/daemon/daemon_rpc.h @@ -86,6 +86,10 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl void on_intent_create(const IntentCreateRequest* request, grpc::ServerReaderWriter* server, DaemonRpcContext* context); + void on_intent_add_member( + const IntentAddMemberRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); void on_intent_list(const IntentListRequest* request, grpc::ServerReaderWriter* server, DaemonRpcContext* context); @@ -244,6 +248,9 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl grpc::Status intent_create( grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; + grpc::Status intent_add_member( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; grpc::Status intent_list( grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index fd87813cf6..4611b7688f 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -45,6 +45,7 @@ service Rpc { rpc restore (stream RestoreRequest) returns (stream RestoreReply); rpc clone (stream CloneRequest) returns (stream CloneReply); rpc intent_create (stream IntentCreateRequest) returns (stream IntentCreateReply); + rpc intent_add_member (stream IntentAddMemberRequest) returns (stream IntentAddMemberReply); rpc intent_list (stream IntentListRequest) returns (stream IntentListReply); rpc intent_info (stream IntentInfoRequest) returns (stream IntentInfoReply); rpc intent_delete (stream IntentDeleteRequest) returns (stream IntentDeleteReply); @@ -601,6 +602,17 @@ message IntentCreateReply { string reply_message = 2; } +message IntentAddMemberRequest { + string name = 1; + repeated IntentMemberRequest members = 2; + int32 verbosity_level = 3; +} + +message IntentAddMemberReply { + string log_line = 1; + string reply_message = 2; +} + message IntentMember { string role = 1; string instance_name = 2; From 39708fa441c3f95270dc998c628fd867f4796ce3 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 16:24:04 +0200 Subject: [PATCH 17/27] Rework GUI intent support: dedicated management page, dropdown-only picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier metadata-tagging-only approach (free-text intent/role fields in the launch form) with a registry-consistent workflow, per feedback: intents are only usable in the GUI once they already exist. - New Intents page (src/client/gui/lib/intents/intents_screen.dart, sidebar entry "Intents"): create an intent with one or more members (role + optional image, template-backed when image is blank), add a member to an existing intent, delete an intent, and see each member's live status — all backed by the daemon's intent_create/add/list/info/delete RPCs via new GrpcClient wrappers and a polling intentsStreamProvider. - Launch form: the intent section is now a dropdown of existing intents only (default "None"), not a free-text field — no more tagging an intent that doesn't exist in the registry. Selecting one launches via intent_add_member instead of a plain launch, so the new instance is properly tracked; mounts and bridged networking are hidden in that mode since intent_add_member doesn't support them yet. - Instance list (vm_table/vms.dart): kept the intent filter dropdown from the previous pass, and added a "Group by intent" toggle that renders one Table per intent (plus one for untagged instances) instead of a single flat table. Not yet analyzed or built — no Dart toolchain available on this host to even run `dart analyze` (the vendored 3rd-party/flutter submodule isn't checked out outside the sandboxed build chroot). --- src/client/gui/lib/catalogue/launch_form.dart | 165 ++++++- src/client/gui/lib/grpc_client.dart | 20 + .../gui/lib/intents/intents_screen.dart | 417 ++++++++++++++++++ src/client/gui/lib/main.dart | 2 + src/client/gui/lib/providers.dart | 27 ++ src/client/gui/lib/sidebar.dart | 11 + src/client/gui/lib/vm_table/vms.dart | 131 +++++- 7 files changed, 750 insertions(+), 23 deletions(-) create mode 100644 src/client/gui/lib/intents/intents_screen.dart diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 724985014c..a73120b5fa 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -130,6 +130,10 @@ class _LaunchFormState extends ConsumerState { var addingMount = false; final scrollController = ScrollController(); final cloudInitSectionKey = GlobalKey(); + String? _selectedIntent; + String _intentRole = ''; + bool _addingToIntent = false; + String? _intentError; String? _cloudInitError; @override @@ -178,6 +182,7 @@ class _LaunchFormState extends ConsumerState { loading: () => null, error: (_, __) => null, ); + final intentNames = ref.watch(intentNamesProvider); final closeButton = IconButton( icon: const Icon(Icons.close), @@ -249,6 +254,68 @@ class _LaunchFormState extends ConsumerState { }, ); + final intentDropdown = Dropdown( + label: 'Intent', + width: 360, + value: _selectedIntent, + onChanged: (value) => setState(() { + _selectedIntent = value; + _intentError = null; + }), + items: { + null: 'None (standalone instance)', + for (final existingIntent in intentNames) existingIntent: existingIntent, + }, + ); + + final intentRoleInput = SpecInput( + label: 'Role in intent', + helper: 'What this instance is within the intent (e.g. "redis").', + hint: 'e.g. redis', + initialValue: _intentRole, + onSaved: (value) => _intentRole = value ?? '', + width: 360, + ); + + final intentNote = _selectedIntent == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Launching into an existing intent doesn\'t support mounts or ' + 'bridged networking yet; those sections are hidden below.', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ); + + final intentSection = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + intentDropdown, + if (_selectedIntent != null) ...[ + const SizedBox(width: 24), + intentRoleInput, + ], + ], + ), + intentNote, + if (_intentError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _intentError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + final mountPointsView = MountPointsView( allowDelete: true, mounts: mountRequests.map( @@ -360,12 +427,21 @@ class _LaunchFormState extends ConsumerState { ], ), const Divider(height: 60), - SizedBox( + const SizedBox( height: 50, - child: Text(l10n.bridgeTitle, style: const TextStyle(fontSize: 24)), + child: Text('Intent', style: TextStyle(fontSize: 24)), ), - bridgedSwitch, + intentSection, const Divider(height: 60), + if (_selectedIntent == null) ...[ + SizedBox( + height: 50, + child: + Text(l10n.bridgeTitle, style: const TextStyle(fontSize: 24)), + ), + bridgedSwitch, + const Divider(height: 60), + ], KeyedSubtree( key: cloudInitSectionKey, child: Column( @@ -436,14 +512,17 @@ class _LaunchFormState extends ConsumerState { ], ), ), - const Divider(height: 60), - SizedBox( - height: 50, - child: Text(l10n.mountsTitle, style: const TextStyle(fontSize: 24)), - ), - mountPointsView, - if (mountRequests.isNotEmpty) const SizedBox(height: 20), - addingMount ? mountForm : addMountButton, + if (_selectedIntent == null) ...[ + const Divider(height: 60), + SizedBox( + height: 50, + child: + Text(l10n.mountsTitle, style: const TextStyle(fontSize: 24)), + ), + mountPointsView, + if (mountRequests.isNotEmpty) const SizedBox(height: 20), + addingMount ? mountForm : addMountButton, + ], ], ); @@ -582,13 +661,16 @@ class _LaunchFormState extends ConsumerState { mountRequest.targetPaths.first.instanceName = launchRequest.instanceName; } - final started = await initiateLaunchFlow( - context, - ref, - launchRequest.deepCopy(), - mountRequests: mountRequests.map((r) => r.deepCopy()).toList(), - os: imageInfo.os, - ); + final selectedIntent = _selectedIntent; + final started = selectedIntent == null + ? await initiateLaunchFlow( + context, + ref, + launchRequest.deepCopy(), + mountRequests: mountRequests.map((r) => r.deepCopy()).toList(), + os: imageInfo.os, + ) + : await _addToIntent(selectedIntent); if (!started || !mounted) return; @@ -601,6 +683,53 @@ class _LaunchFormState extends ConsumerState { .set(elpVm(launchRequest.instanceName).sidebarKey); } } + + /// Launches this form's instance as a new member of an existing intent via + /// intent_add_member instead of a plain launch, so it's tracked in the + /// daemon's intent registry (`elp intent info` etc.) rather than just + /// carrying the intent/intentRole tag on an untracked instance. Mounts and + /// bridged networking aren't supported by intent_add_member yet (their + /// sections are hidden in the form while an intent is selected). + Future _addToIntent(String intentName) async { + if (_intentRole.trim().isEmpty) { + setState(() => _intentError = 'Please provide a role for this member.'); + return false; + } + + setState(() { + _addingToIntent = true; + _intentError = null; + }); + + try { + final member = IntentMemberRequest( + role: _intentRole.trim(), + image: launchRequest.image, + numCores: launchRequest.numCores, + memSize: launchRequest.memSize, + diskSpace: launchRequest.diskSpace, + ); + if (launchRequest.hasCloudInitUserData()) { + member.cloudInitUserData = launchRequest.cloudInitUserData; + } + + final reply = await ref.read(grpcClientProvider).intentAddMember( + IntentAddMemberRequest(name: intentName, members: [member]), + ); + + ref.read(recentActivityProvider.notifier).record( + title: 'Added ${_intentRole.trim()} to intent $intentName', + detail: reply?.replyMessage ?? '', + ); + return true; + } catch (error) { + if (!mounted) return false; + setState(() => _intentError = '$error'); + return false; + } finally { + if (mounted) setState(() => _addingToIntent = false); + } + } } Future initiateLaunchFlow( diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index cb51f74135..116ce8f39d 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -400,6 +400,26 @@ class GrpcClient { ).then((r) => r!); } + Future intentCreate(IntentCreateRequest request) { + return doRpc(_client.intent_create, request); + } + + Future intentAddMember(IntentAddMemberRequest request) { + return doRpc(_client.intent_add_member, request); + } + + Future> intentList() { + return doRpc(_client.intent_list, IntentListRequest(), log: false) + .then((r) => r?.intents.toList() ?? const []); + } + + Future intentDelete(String name, {bool purge = false}) { + return doRpc( + _client.intent_delete, + IntentDeleteRequest(name: name, purge: purge), + ); + } + Future authenticate(String passphrase) { return doRpc( _client.authenticate, diff --git a/src/client/gui/lib/intents/intents_screen.dart b/src/client/gui/lib/intents/intents_screen.dart new file mode 100644 index 0000000000..c33e5db1ae --- /dev/null +++ b/src/client/gui/lib/intents/intents_screen.dart @@ -0,0 +1,417 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../confirmation_dialog.dart'; +import '../layout/compact_layout.dart'; +import '../page_surface.dart'; +import '../providers.dart'; +import '../sidebar.dart'; +import '../vm_details/vm_status_icon.dart'; +import '../widgets/launchpad_button.dart'; + +/// A named group of instances launched together (e.g. "test-app-1" = redis + +/// postgres). Backed by the daemon's own intent registry (`elp intent +/// create/add/list/info/delete`); this screen is a GUI front-end for the +/// same registry, not a separate concept. +class IntentsScreen extends ConsumerWidget { + static const sidebarKey = 'intents'; + + const IntentsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final intentsAsync = ref.watch(intentsStreamProvider); + final onSurface = Theme.of(context).colorScheme.onSurface; + + return Scaffold( + body: PageSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Intents', + style: TextStyle(fontSize: 37, fontWeight: FontWeight.w300), + ), + ), + LaunchPadButton.primary( + onPressed: () => showCreateIntentDialog(context, ref), + child: const Text('New intent'), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Named groups of instances launched together, e.g. a "redis" and ' + 'a "postgres" instance for the same app.', + style: TextStyle(fontSize: 14, color: onSurface.withValues(alpha: 0.7)), + ), + const SizedBox(height: 24), + Expanded( + child: intentsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('$error')), + data: (intents) => intents.isEmpty + ? Center( + child: Text( + 'No intents yet. Create one to launch instances as a ' + 'named group.', + style: TextStyle(color: onSurface.withValues(alpha: 0.6)), + ), + ) + : ListView.separated( + itemCount: intents.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) => + _IntentCard(intent: intents[index]), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _IntentCard extends ConsumerWidget { + const _IntentCard({required this.intent}); + + final IntentInfo intent; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final onSurface = Theme.of(context).colorScheme.onSurface; + + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: onSurface.withValues(alpha: 0.15)), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + intent.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + tooltip: 'Add member', + icon: const Icon(Icons.add), + onPressed: () => showAddMemberDialog(context, ref, intent.name), + ), + IconButton( + tooltip: 'Delete intent', + icon: const Icon(Icons.delete_outline), + onPressed: () => showDeleteIntentDialog(context, ref, intent.name), + ), + ], + ), + if (intent.members.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'No members yet.', + style: TextStyle(color: onSurface.withValues(alpha: 0.6)), + ), + ) + else + ...intent.members.map( + (member) => Padding( + padding: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: () => ref + .read(sidebarKeyProvider.notifier) + .set(elpVm(member.instanceName).sidebarKey), + child: Row( + children: [ + VmStatusIcon( + member.instanceStatus.status, + isLaunching: false, + ), + const SizedBox(width: 12), + Text(member.role, + style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Text( + '(${member.instanceName})', + style: TextStyle(color: onSurface.withValues(alpha: 0.6)), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _MemberFields { + _MemberFields() + : roleController = TextEditingController(), + imageController = TextEditingController(); + + final TextEditingController roleController; + final TextEditingController imageController; + + void dispose() { + roleController.dispose(); + imageController.dispose(); + } + + IntentMemberRequest? toRequest() { + final role = roleController.text.trim(); + if (role.isEmpty) return null; + return IntentMemberRequest( + role: role, + image: imageController.text.trim(), + ); + } +} + +Widget _memberFieldsRow( + _MemberFields fields, { + VoidCallback? onRemove, +}) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: fields.roleController, + decoration: const InputDecoration( + labelText: 'Role', + hintText: 'e.g. redis', + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: fields.imageController, + decoration: const InputDecoration( + labelText: 'Image (optional)', + hintText: 'blank = use "redis"/"postgres" template', + ), + ), + ), + if (onRemove != null) + IconButton( + icon: const Icon(Icons.close), + onPressed: onRemove, + ), + ], + ), + ); +} + +Future showCreateIntentDialog(BuildContext context, WidgetRef ref) async { + final nameController = TextEditingController(); + final members = [_MemberFields()]; + String? error; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + shape: const Border(), + title: const Text('New intent'), + content: SizedBox( + width: CompactLayout.dialogWidth(dialogContext, 480), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: nameController, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Intent name', + hintText: 'e.g. test-app-1', + ), + ), + const SizedBox(height: 16), + const Text('Members', style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + for (final member in members) + _memberFieldsRow( + member, + onRemove: members.length > 1 + ? () => setDialogState(() => members.remove(member)) + : null, + ), + TextButton.icon( + onPressed: () => + setDialogState(() => members.add(_MemberFields())), + icon: const Icon(Icons.add, size: 18), + label: const Text('Add another member'), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + error!, + style: + TextStyle(color: Theme.of(dialogContext).colorScheme.error), + ), + ), + ], + ), + ), + ), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + LaunchPadButton.primary( + onPressed: () async { + final name = nameController.text.trim(); + if (name.isEmpty) { + setDialogState(() => error = 'Please provide an intent name.'); + return; + } + final requests = members.map((m) => m.toRequest()).toList(); + if (requests.any((r) => r == null)) { + setDialogState(() => error = 'Every member needs a role.'); + return; + } + + try { + await ref.read(grpcClientProvider).intentCreate( + IntentCreateRequest( + name: name, + members: requests.whereType(), + ), + ); + ref.invalidate(intentsStreamProvider); + if (dialogContext.mounted) Navigator.pop(dialogContext); + } catch (e) { + if (dialogContext.mounted) setDialogState(() => error = '$e'); + } + }, + child: const Text('Create'), + ), + ], + ), + ), + ); + + nameController.dispose(); + for (final member in members) { + member.dispose(); + } +} + +Future showAddMemberDialog( + BuildContext context, + WidgetRef ref, + String intentName, +) async { + final member = _MemberFields(); + String? error; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + shape: const Border(), + title: Text('Add member to $intentName'), + content: SizedBox( + width: CompactLayout.dialogWidth(dialogContext, 480), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _memberFieldsRow(member), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + error!, + style: TextStyle(color: Theme.of(dialogContext).colorScheme.error), + ), + ), + ], + ), + ), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + LaunchPadButton.primary( + onPressed: () async { + final request = member.toRequest(); + if (request == null) { + setDialogState(() => error = 'Please provide a role.'); + return; + } + try { + await ref.read(grpcClientProvider).intentAddMember( + IntentAddMemberRequest(name: intentName, members: [request]), + ); + ref.invalidate(intentsStreamProvider); + if (dialogContext.mounted) Navigator.pop(dialogContext); + } catch (e) { + if (dialogContext.mounted) setDialogState(() => error = '$e'); + } + }, + child: const Text('Add'), + ), + ], + ), + ), + ); + + member.dispose(); +} + +Future showDeleteIntentDialog( + BuildContext context, + WidgetRef ref, + String intentName, +) async { + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ConfirmationDialog( + title: 'Delete intent', + body: Text( + 'Delete "$intentName"? Its member instances will be deleted too.', + ), + actionText: 'Delete', + onAction: () => Navigator.pop(dialogContext, true), + inactionText: 'Cancel', + onInaction: () => Navigator.pop(dialogContext, false), + ), + ); + if (confirmed != true) return; + + try { + await ref.read(grpcClientProvider).intentDelete(intentName, purge: true); + ref.invalidate(intentsStreamProvider); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } +} diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index 30fee8da2f..10228c65c2 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -21,6 +21,7 @@ import 'cloud_init/cloud_init_screen.dart'; import 'daemon_unavailable.dart'; import 'downloads/download_status_list.dart'; import 'help.dart'; +import 'intents/intents_screen.dart'; import 'layout/compact_layout.dart'; import 'logger.dart'; import 'llm/catalogue/llm_catalogue_screen.dart'; @@ -272,6 +273,7 @@ class _AppState extends ConsumerState with WindowListener { }, CacheScreen.sidebarKey: const CacheScreen(), CloudInitScreen.sidebarKey: const CloudInitScreen(), + IntentsScreen.sidebarKey: const IntentsScreen(), SettingsScreen.sidebarKey: const SettingsScreen(), HelpScreen.sidebarKey: const HelpScreen(), for (final id in vms) id.sidebarKey: VmDetailsScreen(id), diff --git a/src/client/gui/lib/providers.dart b/src/client/gui/lib/providers.dart index eec7b1b9a5..305400f896 100644 --- a/src/client/gui/lib/providers.dart +++ b/src/client/gui/lib/providers.dart @@ -299,6 +299,33 @@ final daemonInfoProvider = StreamProvider((ref) async* { } }); +/// Named intent groups (e.g. "test-app-1" = redis + postgres), polled from the +/// daemon's own registry (see `elp intent list`). Used for the launch form's +/// intent picker and the instance list's intent filter. +final intentsStreamProvider = StreamProvider>((ref) async* { + if (!ref.watch(daemonAvailableProvider)) { + yield const []; + return; + } + final grpcClient = ref.watch(grpcClientProvider); + while (true) { + final timer = Future.delayed(1900.milliseconds); + try { + yield await grpcClient.intentList(); + } catch (error, stackTrace) { + logger.w('Error on polling intent_list', error: error, stackTrace: stackTrace); + yield const []; + } + await timer; + await Future.delayed(100.milliseconds); + } +}); + +final intentNamesProvider = Provider>((ref) { + final intents = ref.watch(intentsStreamProvider).asData?.value ?? const []; + return {for (final intent in intents) intent.name}.toBuiltSet(); +}); + class AllVmInfosNotifier extends Notifier> { @override List build() { diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index f9783d653f..2f63d93685 100644 --- a/src/client/gui/lib/sidebar.dart +++ b/src/client/gui/lib/sidebar.dart @@ -20,6 +20,7 @@ import 'cloud_init/cloud_init_screen.dart'; import 'downloads/download_manager.dart'; import 'glass_panel.dart'; import 'help.dart'; +import 'intents/intents_screen.dart'; import 'l10n/app_localizations.dart'; import 'layout/compact_layout.dart'; import 'llm/catalogue/llm_catalogue_screen.dart'; @@ -300,6 +301,15 @@ class SideBar extends ConsumerWidget { }, ); + final intents = SidebarEntry( + icon: FontAwesomeIcons.objectGroup, + selected: isSelected(IntentsScreen.sidebarKey), + label: 'Intents', + onPressed: () { + ref.read(sidebarKeyNotifier).set(IntentsScreen.sidebarKey); + }, + ); + final instances = SidebarEntry( icon: FontAwesomeIcons.server, selected: isSelected(VmTableScreen.sidebarKey) || @@ -559,6 +569,7 @@ class SideBar extends ConsumerWidget { SidebarSectionHeader(l10n.sidebarSectionCompute), catalogue, instances, + intents, cloudInit, SidebarSectionHeader( l10n.sidebarSectionAi, diff --git a/src/client/gui/lib/vm_table/vms.dart b/src/client/gui/lib/vm_table/vms.dart index f2cff56748..412d570e20 100644 --- a/src/client/gui/lib/vm_table/vms.dart +++ b/src/client/gui/lib/vm_table/vms.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../catalogue/catalogue.dart'; import '../daemon_source.dart'; +import '../dropdown.dart'; import '../l10n/app_localizations.dart'; import '../layout/compact_layout.dart'; import '../llm/host_resource_gauges.dart'; @@ -36,6 +37,34 @@ final runningOnlyProvider = NotifierProvider( RunningOnlyNotifier.new, ); +class GroupByIntentNotifier extends Notifier { + @override + bool build() => false; + + void set(bool value) { + state = value; + } +} + +final groupByIntentProvider = NotifierProvider( + GroupByIntentNotifier.new, +); + +class SelectedIntentNotifier extends Notifier { + @override + String? build() => null; + + void set(String? value) { + state = value; + } +} + +/// null means "all intents" (no filtering); the empty string filters down +/// to instances that aren't tagged with any intent at all. +final selectedIntentProvider = NotifierProvider( + SelectedIntentNotifier.new, +); + final selectedVmsProvider = NotifierProvider>( SelectedVmsNotifier.new, @@ -46,6 +75,7 @@ class SelectedVmsNotifier extends Notifier> { BuiltSet build() { ref.watch(runningOnlyProvider); ref.watch(searchNameProvider); + ref.watch(selectedIntentProvider); ref.watch(sidebarKeyProvider); ref.listen(vmIdsProvider, (_, availableIds) { state = availableIds.intersection(state); @@ -86,6 +116,25 @@ class Vms extends ConsumerWidget { final searchName = ref.watch(searchNameProvider); final runningOnly = ref.watch(runningOnlyProvider); + final selectedIntent = ref.watch(selectedIntentProvider); + final intentNames = ref.watch(intentNamesProvider); + final intentFilter = intentNames.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(right: 8), + child: Dropdown( + label: 'Intent', + width: 200, + value: selectedIntent, + onChanged: (v) => ref.read(selectedIntentProvider.notifier).set(v), + items: { + null: 'All intents', + '': 'No intent', + for (final intentName in intentNames) intentName: intentName, + }, + ), + ); + final groupByIntent = ref.watch(groupByIntentProvider); final vmFilters = Row( children: [ Switch( @@ -93,7 +142,14 @@ class Vms extends ConsumerWidget { value: runningOnly, onChanged: (v) => ref.read(runningOnlyProvider.notifier).set(v), ), + const SizedBox(width: 16), + Switch( + label: 'Group by intent', + value: groupByIntent, + onChanged: (v) => ref.read(groupByIntentProvider.notifier).set(v), + ), const Spacer(), + intentFilter, const SearchBox(), const SizedBox(width: 8), const HeaderSelection(), @@ -109,6 +165,7 @@ class Vms extends ConsumerWidget { .watch(vmInfosProvider) .where((i) => !runningOnly || i.instanceStatus.status == Status.RUNNING) .where((i) => i.name.contains(searchName)) + .where((i) => selectedIntent == null || i.info.intent == selectedIntent) .toList(); int total(Iterable it) => it.map((e) => int.tryParse(e) ?? 0).sum; @@ -159,14 +216,78 @@ class Vms extends ConsumerWidget { Flexible( child: Padding( padding: const EdgeInsets.all(8), + child: groupByIntent + ? _GroupedByIntentTables( + infos: infos, + headers: enabledHeaders, + isSelected: (info) => selectedVms.contains(info.id), + ) + : Table( + headers: enabledHeaders, + data: infos.toList(), + finalRow: totalUsageRow, + isSelected: (info) => selectedVms.contains(info.id), + ), + ), + ), + ], + ); + } +} + +/// One [Table] per intent (plus one for untagged instances), stacked in a +/// scrollable column. Each table is given an explicit height sized to its +/// row count, since [Table] (a 2D scrollable) needs a bounded height and +/// can't just be dropped into an unbounded-height [ListView] like a normal +/// widget. +class _GroupedByIntentTables extends StatelessWidget { + const _GroupedByIntentTables({ + required this.infos, + required this.headers, + required this.isSelected, + }); + + final List infos; + final List> headers; + final bool Function(TaggedVmInfo) isSelected; + + static const _headerRowHeight = 56.0; + + @override + Widget build(BuildContext context) { + final groups = >{}; + for (final info in infos) { + groups.putIfAbsent(info.info.intent, () => []).add(info); + } + final sortedKeys = groups.keys.toList() + ..sort((a, b) { + if (a.isEmpty || b.isEmpty) return a.isEmpty ? 1 : -1; + return a.compareTo(b); + }); + + if (sortedKeys.isEmpty) return const SizedBox.shrink(); + + return ListView( + children: [ + for (final key in sortedKeys) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + key.isEmpty ? 'No intent' : key, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + SizedBox( + height: _headerRowHeight + groups[key]!.length * 50, child: Table( - headers: enabledHeaders, - data: infos.toList(), - finalRow: totalUsageRow, - isSelected: (info) => selectedVms.contains(info.id), + headers: headers, + data: groups[key]!, + finalRow: const [], + isSelected: isSelected, ), ), - ), + const SizedBox(height: 16), + ], ], ); } From 49dcc58fe38c0b1c49493875a642d307a08d0993 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 16:33:32 +0200 Subject: [PATCH 18/27] Make IntentMemberLaunchSink generic over the outer reply/request types Its constructor was hardcoded to grpc::ServerReaderWriterInterface*, so intent_add_member (which needs to wrap an IntentAddMemberReply/Request stream instead) failed to compile with "no matching function for call to IntentMemberLaunchSink::IntentMemberLaunchSink". Templated the class on OuterReply/OuterRequest so both intent_create and intent_add_member can share it, and pass explicit template arguments at each call site. --- src/daemon/daemon.cpp | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index 78a905194d..dc4a73dbf8 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -3138,15 +3138,18 @@ catch (const std::exception& e) namespace { -// Bridges a synthetic, internal `launch` invocation (intent_create reuses -// Daemon::create_vm to launch each member) into the real IntentCreateReply -// stream the client actually sees. +// Bridges a synthetic, internal `launch` invocation (intent_create and +// intent_add_member both reuse Daemon::create_vm to launch each member) +// into whichever real reply stream the client actually sees — IntentCreateReply +// for intent_create, IntentAddMemberReply for intent_add_member. Templated on +// the outer reply/request types so both call sites can share this adapter. +template class IntentMemberLaunchSink : public grpc::ServerReaderWriterInterface { public: explicit IntentMemberLaunchSink( - grpc::ServerReaderWriterInterface* outer) + grpc::ServerReaderWriterInterface* outer) : outer{outer} { } @@ -3170,7 +3173,7 @@ class IntentMemberLaunchSink { if (!reply.log_line().empty()) { - mp::IntentCreateReply forwarded; + OuterReply forwarded; forwarded.set_log_line(reply.log_line()); outer->Write(forwarded); } @@ -3178,7 +3181,7 @@ class IntentMemberLaunchSink } private: - grpc::ServerReaderWriterInterface* outer; + grpc::ServerReaderWriterInterface* outer; }; // A one-shot DaemonRpcContext for a single intent member's internal launch: @@ -3341,7 +3344,7 @@ try auto launch_requests = std::make_shared>(std::move(*built)); std::shared_ptr> sink = - std::make_shared(server); + std::make_shared>(server); launch_intent_members( launch_requests, @@ -3390,7 +3393,8 @@ try auto launch_requests = std::make_shared>(std::move(*built)); std::shared_ptr> sink = - std::make_shared(server); + std::make_shared>( + server); launch_intent_members( launch_requests, From e155d789bb7001baea269e961f0ca8031ed3104d Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 17:02:27 +0200 Subject: [PATCH 19/27] Allow empty-member intents; carry service_id through intent members - intent_create (daemon + CLI) no longer rejects zero members: an intent can be created as a named, empty group and populated later via `elp intent add` / intent_add_member. launch_intent_members already handled an empty launch_requests vector correctly (the base case fires immediately), so no chaining logic changed. - GUI Intents page's create dialog now starts with zero member rows and lets every row be removed, instead of requiring at least one. - Launch form: the intent dropdown gained a "+ Create new intent..." option (calling intent_create with one member) alongside existing intents (intent_add_member), not just existing ones. - IntentMemberRequest gained service_id, copied into the internal LaunchRequest by build_intent_member_launch_requests: a marketplace service deployed into an intent is still recognized as a service instance (isServiceVmInfo/service bindings), not just a plain instance. --- src/client/cli/cmd/intent.cpp | 8 +- src/client/gui/lib/catalogue/launch_form.dart | 89 ++++++++++++++----- .../gui/lib/intents/intents_screen.dart | 16 ++-- src/daemon/daemon.cpp | 7 +- src/rpc/multipass.proto | 5 ++ 5 files changed, 90 insertions(+), 35 deletions(-) diff --git a/src/client/cli/cmd/intent.cpp b/src/client/cli/cmd/intent.cpp index d135722a87..20b2664fe1 100644 --- a/src/client/cli/cmd/intent.cpp +++ b/src/client/cli/cmd/intent.cpp @@ -175,12 +175,8 @@ mp::ReturnCodeVariant cmd::Intent::run_create(ArgParser* parser) cerr << "Please provide a name for the intent.\n"; return parser->returnCodeFrom(ParseCode::CommandLineError); } - if (!parser->isSet(service_option) && !parser->isSet(instance_option)) - { - cerr << "Please specify at least one member with --service or --instance.\n"; - return parser->returnCodeFrom(ParseCode::CommandLineError); - } - + // --service/--instance are optional here: an intent can be created empty + // and populated later with `elp intent add`. IntentCreateRequest request; request.set_name(args[1].toStdString()); request.set_verbosity_level(parser->verbosityLevel()); diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index a73120b5fa..67db273944 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -81,6 +81,11 @@ String imageName(ImageInfo imageInfo) { : '$result ${imageInfo.codename}'; } +/// Sentinel dropdown value for "create a new intent" (distinct from an +/// existing intent name and from null/"None"): a leading NUL can never be +/// typed into a text field, so this can't collide with a real intent name. +const _createNewIntentValue = '\u0000__create_new_intent__'; + final defaultCpus = 1; final defaultRam = 1.gibi; final defaultDisk = 5.gibi; @@ -131,6 +136,7 @@ class _LaunchFormState extends ConsumerState { final scrollController = ScrollController(); final cloudInitSectionKey = GlobalKey(); String? _selectedIntent; + String _newIntentName = ''; String _intentRole = ''; bool _addingToIntent = false; String? _intentError; @@ -264,10 +270,19 @@ class _LaunchFormState extends ConsumerState { }), items: { null: 'None (standalone instance)', + _createNewIntentValue: '+ Create new intent...', for (final existingIntent in intentNames) existingIntent: existingIntent, }, ); + final newIntentNameInput = SpecInput( + label: 'New intent name', + hint: 'e.g. test-app-1', + initialValue: _newIntentName, + onSaved: (value) => _newIntentName = value ?? '', + width: 360, + ); + final intentRoleInput = SpecInput( label: 'Role in intent', helper: 'What this instance is within the intent (e.g. "redis").', @@ -282,8 +297,8 @@ class _LaunchFormState extends ConsumerState { : Padding( padding: const EdgeInsets.only(top: 8), child: Text( - 'Launching into an existing intent doesn\'t support mounts or ' - 'bridged networking yet; those sections are hidden below.', + 'Launching into an intent doesn\'t support mounts or bridged ' + 'networking yet; those sections are hidden below.', style: TextStyle( fontSize: 13, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), @@ -298,6 +313,10 @@ class _LaunchFormState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ intentDropdown, + if (_selectedIntent == _createNewIntentValue) ...[ + const SizedBox(width: 24), + newIntentNameInput, + ], if (_selectedIntent != null) ...[ const SizedBox(width: 24), intentRoleInput, @@ -662,15 +681,20 @@ class _LaunchFormState extends ConsumerState { } final selectedIntent = _selectedIntent; - final started = selectedIntent == null - ? await initiateLaunchFlow( - context, - ref, - launchRequest.deepCopy(), - mountRequests: mountRequests.map((r) => r.deepCopy()).toList(), - os: imageInfo.os, - ) - : await _addToIntent(selectedIntent); + final bool started; + if (selectedIntent == null) { + started = await initiateLaunchFlow( + context, + ref, + launchRequest.deepCopy(), + mountRequests: mountRequests.map((r) => r.deepCopy()).toList(), + os: imageInfo.os, + ); + } else if (selectedIntent == _createNewIntentValue) { + started = await _launchIntoIntent(newIntentName: _newIntentName.trim()); + } else { + started = await _launchIntoIntent(existingIntentName: selectedIntent); + } if (!started || !mounted) return; @@ -684,13 +708,24 @@ class _LaunchFormState extends ConsumerState { } } - /// Launches this form's instance as a new member of an existing intent via - /// intent_add_member instead of a plain launch, so it's tracked in the + /// Launches this form's instance as a member of an intent — either a brand + /// new one (via intent_create) or an already-existing one (via + /// intent_add_member) — instead of a plain launch, so it's tracked in the /// daemon's intent registry (`elp intent info` etc.) rather than just /// carrying the intent/intentRole tag on an untracked instance. Mounts and - /// bridged networking aren't supported by intent_add_member yet (their - /// sections are hidden in the form while an intent is selected). - Future _addToIntent(String intentName) async { + /// bridged networking aren't supported by either RPC yet (their sections + /// are hidden in the form while an intent is selected). Pass exactly one + /// of [newIntentName] or [existingIntentName]. + Future _launchIntoIntent({ + String? newIntentName, + String? existingIntentName, + }) async { + assert((newIntentName == null) != (existingIntentName == null)); + + if (newIntentName != null && newIntentName.isEmpty) { + setState(() => _intentError = 'Please provide a name for the new intent.'); + return false; + } if (_intentRole.trim().isEmpty) { setState(() => _intentError = 'Please provide a role for this member.'); return false; @@ -713,13 +748,27 @@ class _LaunchFormState extends ConsumerState { member.cloudInitUserData = launchRequest.cloudInitUserData; } - final reply = await ref.read(grpcClientProvider).intentAddMember( - IntentAddMemberRequest(name: intentName, members: [member]), - ); + final grpcClient = ref.read(grpcClientProvider); + final String intentName; + String replyMessage; + if (newIntentName != null) { + intentName = newIntentName; + final reply = await grpcClient.intentCreate( + IntentCreateRequest(name: intentName, members: [member]), + ); + replyMessage = reply?.replyMessage ?? ''; + } else { + intentName = existingIntentName!; + final reply = await grpcClient.intentAddMember( + IntentAddMemberRequest(name: intentName, members: [member]), + ); + replyMessage = reply?.replyMessage ?? ''; + } + ref.invalidate(intentsStreamProvider); ref.read(recentActivityProvider.notifier).record( title: 'Added ${_intentRole.trim()} to intent $intentName', - detail: reply?.replyMessage ?? '', + detail: replyMessage, ); return true; } catch (error) { diff --git a/src/client/gui/lib/intents/intents_screen.dart b/src/client/gui/lib/intents/intents_screen.dart index c33e5db1ae..f5f5ea8c13 100644 --- a/src/client/gui/lib/intents/intents_screen.dart +++ b/src/client/gui/lib/intents/intents_screen.dart @@ -223,7 +223,9 @@ Widget _memberFieldsRow( Future showCreateIntentDialog(BuildContext context, WidgetRef ref) async { final nameController = TextEditingController(); - final members = [_MemberFields()]; + // Starts empty: an intent can be created with no members at all and + // populated later via "Add member". + final members = <_MemberFields>[]; String? error; await showDialog( @@ -249,20 +251,22 @@ Future showCreateIntentDialog(BuildContext context, WidgetRef ref) async { ), ), const SizedBox(height: 16), - const Text('Members', style: TextStyle(fontWeight: FontWeight.w500)), + const Text('Members (optional)', style: TextStyle(fontWeight: FontWeight.w500)), + const Text( + 'Add now, or leave empty and add members later.', + style: TextStyle(fontSize: 12), + ), const SizedBox(height: 8), for (final member in members) _memberFieldsRow( member, - onRemove: members.length > 1 - ? () => setDialogState(() => members.remove(member)) - : null, + onRemove: () => setDialogState(() => members.remove(member)), ), TextButton.icon( onPressed: () => setDialogState(() => members.add(_MemberFields())), icon: const Icon(Icons.add, size: 18), - label: const Text('Add another member'), + label: const Text('Add a member'), ), if (error != null) Padding( diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index dc4a73dbf8..a678aaa51d 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -3263,6 +3263,8 @@ std::optional> build_intent_member_launch_request lr.set_disk_space(member.disk_space().empty() ? "5G" : member.disk_space()); lr.set_intent(intent_name); lr.set_intent_role(role); + if (!member.service_id().empty()) + lr.set_service_id(member.service_id()); launch_requests.push_back(std::move(lr)); } @@ -3333,9 +3335,8 @@ try return context->set_value({grpc::StatusCode::INVALID_ARGUMENT, fmt::format("Intent \"{}\" already exists", name), ""}); - if (request->members().empty()) - return context->set_value( - {grpc::StatusCode::INVALID_ARGUMENT, "An intent needs at least one member", ""}); + // Zero members is fine: an intent can be created as an empty named group and + // populated later via intent_add_member. grpc::Status build_error; auto built = build_intent_member_launch_requests(name, request->members(), build_error); diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index 4611b7688f..3012c6f993 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -589,6 +589,11 @@ message IntentMemberRequest { int32 num_cores = 4; string mem_size = 5; string disk_space = 6; + // Marketplace service template id, when this member is a deployed + // service rather than a plain custom/templated instance. Mirrors + // LaunchRequest's own service_id so a service deployed into an intent + // is still recognized as a service instance in the GUI. + string service_id = 7; } message IntentCreateRequest { From 3961a5d46a3f743748b4b574b412d083566b7475 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 17:11:14 +0200 Subject: [PATCH 20/27] Add intent picker to the Catalogue/Deploy dialog Mirrors LaunchForm's intent dropdown ("None" / an existing intent / "+ Create new intent...") in _ServiceDeployDialog, per the requirement that Deploy needs the same treatment as Launch: not only picking an existing intent, but creating a new one inline too. _deployIntoIntent parallels LaunchForm's _launchIntoIntent: it builds an IntentMemberRequest carrying service_id: widget.service.id so the deployed instance is still recognized as a service even when it's a member of an intent rather than launched standalone, and calls intent_create or intent_add_member instead of the plain launch RPC. The service-instance binding is still set unconditionally so the GUI treats it as the deployed service either way. --- .../gui/lib/services/service_deploy.dart | 157 ++++++++++++++++-- 1 file changed, 147 insertions(+), 10 deletions(-) diff --git a/src/client/gui/lib/services/service_deploy.dart b/src/client/gui/lib/services/service_deploy.dart index bff5bdc539..6d1e8da2b7 100644 --- a/src/client/gui/lib/services/service_deploy.dart +++ b/src/client/gui/lib/services/service_deploy.dart @@ -7,9 +7,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../brand.dart'; import '../catalogue/launch_form.dart'; import '../cloud_init/cloud_init_store.dart'; +import '../dropdown.dart'; import '../ffi.dart'; import '../l10n/app_localizations.dart'; import '../layout/compact_layout.dart'; +import '../overview/recent_activity.dart'; import '../providers.dart'; import '../sidebar.dart'; import '../widgets/launchpad_button.dart'; @@ -44,6 +46,12 @@ int serviceDiskBytes(MarketplaceService service) => String serviceCloudInitName(MarketplaceService service) => service.id.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '-'); +/// Sentinel dropdown value for "create a new intent" (mirrors the private +/// constant of the same name in launch_form.dart — a leading NUL can never +/// be typed into a text field, so this can't collide with a real intent +/// name). +const _createNewIntentValue = '\u0000__create_new_intent__'; + Future showServiceDeployDialog( BuildContext context, MarketplaceService service, @@ -70,6 +78,9 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { late final ServiceParameterEditors _parameters; late final String _generatedName; String? _error; + String? _selectedIntent; + String _newIntentName = ''; + String _intentRole = ''; @override void initState() { @@ -106,9 +117,58 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { final vmNames = ref.watch(elpVmNamesProvider); final deletedVms = ref.watch(deletedVmsProvider); final onSurface = Theme.of(context).colorScheme.onSurface; + final intentNames = ref.watch(intentNamesProvider); final minDisk = serviceDiskBytes(service); + final intentDropdown = Dropdown( + label: 'Intent', + width: 360, + value: _selectedIntent, + onChanged: (value) => setState(() { + _selectedIntent = value; + _error = null; + }), + items: { + null: 'None (standalone instance)', + _createNewIntentValue: '+ Create new intent...', + for (final existingIntent in intentNames) existingIntent: existingIntent, + }, + ); + + final intentSection = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + intentDropdown, + if (_selectedIntent == _createNewIntentValue) ...[ + const SizedBox(width: 24), + SpecInput( + label: 'New intent name', + hint: 'e.g. test-app-1', + initialValue: _newIntentName, + onSaved: (value) => _newIntentName = value ?? '', + width: 360, + ), + ], + if (_selectedIntent != null) ...[ + const SizedBox(width: 24), + SpecInput( + label: 'Role in intent', + helper: 'What this service is within the intent (e.g. "redis").', + hint: 'e.g. redis', + initialValue: _intentRole, + onSaved: (value) => _intentRole = value ?? '', + width: 360, + ), + ], + ], + ), + ], + ); + return AlertDialog( title: Row( children: [ @@ -174,6 +234,8 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { min: minDisk, onSaved: (value) => _request.diskSpace = '${value!}B', ), + const SizedBox(height: 12), + intentSection, if (!_parameters.isEmpty) ...[ const Divider(height: 40), ServiceParametersForm(editors: _parameters), @@ -241,20 +303,95 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { .bind(_request.instanceName, service.id); final navigator = Navigator.of(context); final destination = serviceInstanceSidebarKey(_request.instanceName); - final started = await initiateLaunchFlow( - context, - ref, - _request.deepCopy(), - os: _serviceOs, - // The dialog already shows the disk size on a slider. - confirmLargeDisk: false, - successSidebarKey: destination, - ); - if (!started) return; + + final selectedIntent = _selectedIntent; + final bool started; + if (selectedIntent == null) { + started = await initiateLaunchFlow( + context, + ref, + _request.deepCopy(), + os: _serviceOs, + // The dialog already shows the disk size on a slider. + confirmLargeDisk: false, + successSidebarKey: destination, + ); + } else if (selectedIntent == _createNewIntentValue) { + started = await _deployIntoIntent(newIntentName: _newIntentName.trim()); + } else { + started = await _deployIntoIntent(existingIntentName: selectedIntent); + } + if (!started || !mounted) return; navigator.pop(); ref.read(sidebarKeyProvider.notifier).set(destination); } + + /// Deploys this service as a member of an intent — either a brand new one + /// (via intent_create) or an already-existing one (via intent_add_member) + /// — instead of a plain launch, mirroring LaunchForm's own + /// `_launchIntoIntent`. `service_id` is carried on the `IntentMemberRequest` + /// so the instance is still recognized as a deployed service (its bindings + /// are set unconditionally in [_deploy]) even though it's grouped into an + /// intent rather than launched standalone. Pass exactly one of + /// [newIntentName] or [existingIntentName]. + Future _deployIntoIntent({ + String? newIntentName, + String? existingIntentName, + }) async { + assert((newIntentName == null) != (existingIntentName == null)); + + if (newIntentName != null && newIntentName.isEmpty) { + setState(() => _error = 'Please provide a name for the new intent.'); + return false; + } + if (_intentRole.trim().isEmpty) { + setState(() => _error = 'Please provide a role for this member.'); + return false; + } + + try { + final member = IntentMemberRequest( + role: _intentRole.trim(), + image: _request.image, + numCores: _request.numCores, + memSize: _request.memSize, + diskSpace: _request.diskSpace, + serviceId: widget.service.id, + ); + if (_request.hasCloudInitUserData()) { + member.cloudInitUserData = _request.cloudInitUserData; + } + + final grpcClient = ref.read(grpcClientProvider); + final String intentName; + String replyMessage; + if (newIntentName != null) { + intentName = newIntentName; + final reply = await grpcClient.intentCreate( + IntentCreateRequest(name: intentName, members: [member]), + ); + replyMessage = reply?.replyMessage ?? ''; + } else { + intentName = existingIntentName!; + final reply = await grpcClient.intentAddMember( + IntentAddMemberRequest(name: intentName, members: [member]), + ); + replyMessage = reply?.replyMessage ?? ''; + } + + ref.invalidate(intentsStreamProvider); + ref.read(recentActivityProvider.notifier).record( + title: 'Added ${_intentRole.trim()} to intent $intentName', + detail: replyMessage, + ); + return true; + } catch (error) { + if (!mounted) return false; + setState(() => _error = '$error'); + return false; + } + } } /// Stores a service's rendered cloud-init in the user's named config library From c777ef4b476a3b0e8818b30c6d93dafcebce633e Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 17:33:24 +0200 Subject: [PATCH 21/27] Let LLM sessions be true intent members, not just VM instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the intent feature to LLM model sessions end to end, matching the existing VM-instance path rather than adding separate, weaker tagging: - IntentSpec::Member gains a "kind" field ("vm"/"llm", defaulting to "vm" for old persisted registries) so one intent can mix VM and LLM members. - IntentMemberRequest gains model_id (+ quant/ctx_size/runtime/max_tokens): when set, the daemon loads an LLM session via LlmDispatcher/LlmService instead of launching a VM. build_intent_member_launch_requests and the new build_intent_member_load_requests partition a mixed member list by whether model_id is set. - Daemon::launch_intent_llm_members mirrors launch_intent_members' async chaining (one member at a time, since load_model is itself async), reusing the same self-deleting IntentMemberContext. Unlike VM launches (whose create_vm completion is already marshaled back to Daemon's thread via QFutureWatcher), load_model's completion can fire from an LlmDispatcher worker-pool thread, so each member's completion is explicitly re-queued onto Daemon's own thread before touching `intents`. intent_create/ intent_add_member now run the VM chain then the LLM chain and merge both member lists into one IntentSpec. - LoadedSession gains intent/intent_role fields, persisted in llm-sessions.json and surfaced in LoadedModelInfo/list_models; reusing an already-loaded session for the same model_id now backfills its intent tag too instead of leaving it untracked. - intent_list/intent_info/intent_delete branch on member.kind: status for an "llm" member comes from LlmService::has_instance (new, mutex-protected) via a new LlmDispatcher passthrough, and intent_delete unloads LLM members through unload_instances_blocking instead of delete_vm. - CLI: `elp intent create/add --model role:model_id` adds an LLM member with default quant/ctx/runtime; `elp llm load --intent X --intent-role Y` also works for full control over those settings, tagging the session but going through the same intent_create/add_member path. - GUI: the model-load dialog (llm_load.dart) gained the same intent picker (existing intent, or create new) already on the Launch form and Deploy dialog; the Intents page can add an LLM member via a "Model ID" field; the LLM instances table gained an Intent column and search now matches on it too. Fix silent intent launches, add Intents refresh, fix grouped select-all - Launching/deploying/loading into an intent (LaunchForm, service deploy dialog, LLM load dialog) now shows a visible "adding.../added/failed" notification via NotificationsNotifier.addOperation, instead of only a recent-activity entry that's easy to miss — matching the feedback a plain launch already gets via LaunchingNotification. - Fixed a related navigation bug: after an intent-based launch/deploy, the code navigated to elpVm(launchRequest.instanceName)/ serviceInstanceSidebarKey(_request.instanceName) — but the daemon names an intent member "-" itself, ignoring that client-chosen name, so this went to a sidebar key for an instance that was never created. Now navigates to the Intents page instead for either path. - Added a refresh button to the Intents page (intentsStreamProvider already polls, but there was no way to force an immediate refresh). - Fixed "select all" in the instances page's "group by intent" view selecting every instance across every intent instead of just the group it was clicked in: each per-group Table reused the same shared checkbox TableHeader, whose default childBuilder (SelectAllCheckbox) selects from the full, ungrouped instance list. Each group's checkbox column now gets its own _GroupSelectAllCheckbox scoped to that group's own instance ids (via a new SelectedVmsNotifier.toggleAll). --- include/multipass/intent_spec.h | 3 +- src/client/cli/cmd/intent.cpp | 36 +- src/client/cli/cmd/llm.cpp | 20 ++ src/client/cli/cmd/llm.h | 2 + src/client/gui/lib/catalogue/launch_form.dart | 46 ++- .../gui/lib/intents/intents_screen.dart | 92 +++-- .../llm/instances/llm_instance_headers.dart | 14 +- .../llm/instances/llm_instances_screen.dart | 3 +- src/client/gui/lib/llm/llm_load.dart | 209 +++++++++++- .../gui/lib/services/service_deploy.dart | 43 ++- src/client/gui/lib/vm_table/vms.dart | 51 ++- src/daemon/daemon.cpp | 320 +++++++++++++++--- src/daemon/daemon.h | 16 + src/daemon/llm_dispatcher.cpp | 5 + src/daemon/llm_dispatcher.h | 3 + src/llm/llm_service.cpp | 23 ++ src/llm/llm_service.h | 3 + src/rpc/multipass.proto | 19 ++ src/utils/intent_spec.cpp | 4 +- 19 files changed, 782 insertions(+), 130 deletions(-) diff --git a/include/multipass/intent_spec.h b/include/multipass/intent_spec.h index 6f007474a5..439c03b4c0 100644 --- a/include/multipass/intent_spec.h +++ b/include/multipass/intent_spec.h @@ -35,7 +35,8 @@ struct IntentSpec struct Member { std::string role; // e.g. "redis", or a user-chosen label for inline members - std::string instance_name; // the actual launched instance's name + std::string instance_name; // the launched VM instance's name, or an LLM session's instance_id + std::string kind{"vm"}; // "vm" or "llm"; defaults to "vm" for older persisted records friend inline bool operator==(const Member&, const Member&) = default; }; diff --git a/src/client/cli/cmd/intent.cpp b/src/client/cli/cmd/intent.cpp index 20b2664fe1..4508a3b0cf 100644 --- a/src/client/cli/cmd/intent.cpp +++ b/src/client/cli/cmd/intent.cpp @@ -40,6 +40,12 @@ const QCommandLineOption instance_option{ "Add a custom member as role:image:cloud-init-file:cores:mem:disk (trailing fields " "optional, e.g. \"cache:22.04::1:1G:5G\"). Can be repeated.", "spec"}; +const QCommandLineOption model_option{ + "model", + "Add an LLM session member as role:model_id (e.g. \"chat:llama-3.1-8b-instruct\"); " + "loaded with default quant/context/runtime settings (use `elp llm load --intent` for " + "finer control). Can be repeated.", + "spec"}; const QCommandLineOption purge_option{"purge", "Also purge deleted member instances immediately."}; std::string instance_status_name(mp::InstanceStatus::Status status) @@ -68,6 +74,7 @@ mp::ReturnCodeVariant cmd::Intent::run(ArgParser* parser) parser->addPositionalArgument("name", "The intent's name (not needed for `list`)", "[]"); parser->addOption(service_option); parser->addOption(instance_option); + parser->addOption(model_option); parser->addOption(purge_option); const auto status = parser->commandParse(this); @@ -113,8 +120,9 @@ QString cmd::Intent::description() const return QStringLiteral( "Create a named group of instances launched together (e.g. a \"redis\" and a " "\"postgres\" instance for an app), and add to, list, inspect, or delete such " - "groups.\n\n" + "groups (VM instances and/or LLM sessions).\n\n" " elp intent create --service redis --service postgres\n" + " elp intent create --model chat:llama-3.1-8b-instruct\n" " elp intent add --service redis\n" " elp intent list\n" " elp intent info \n" @@ -164,6 +172,21 @@ bool cmd::Intent::parse_members(ArgParser* parser, member->set_disk_space(fields[5].toStdString()); } + for (const auto& spec : parser->values(model_option)) + { + const auto sep = spec.indexOf(':'); + if (sep <= 0 || sep == spec.size() - 1) + { + cerr << fmt::format( + "Invalid --model spec \"{}\"; expected role:model_id.\n", spec.toStdString()); + return false; + } + + auto* member = members->Add(); + member->set_role(spec.left(sep).toStdString()); + member->set_model_id(spec.mid(sep + 1).toStdString()); + } + return true; } @@ -207,9 +230,10 @@ mp::ReturnCodeVariant cmd::Intent::run_add(ArgParser* parser) cerr << "Please provide the name of the intent to add to.\n"; return parser->returnCodeFrom(ParseCode::CommandLineError); } - if (!parser->isSet(service_option) && !parser->isSet(instance_option)) + if (!parser->isSet(service_option) && !parser->isSet(instance_option) && + !parser->isSet(model_option)) { - cerr << "Please specify at least one member with --service or --instance.\n"; + cerr << "Please specify at least one member with --service, --instance, or --model.\n"; return parser->returnCodeFrom(ParseCode::CommandLineError); } @@ -251,9 +275,10 @@ mp::ReturnCodeVariant cmd::Intent::run_list(ArgParser* parser) { cout << intent.name() << "\n"; for (const auto& member : intent.members()) - cout << fmt::format(" {} ({}): {}\n", + cout << fmt::format(" {} ({}) [{}]: {}\n", member.role(), member.instance_name(), + member.kind().empty() ? "vm" : member.kind(), instance_status_name(member.instance_status().status())); } return ReturnCode::Ok; @@ -283,9 +308,10 @@ mp::ReturnCodeVariant cmd::Intent::run_info(ArgParser* parser) cout << fmt::format("Name: {}\n", intent.name()); cout << "Members:\n"; for (const auto& member : intent.members()) - cout << fmt::format(" {} ({}): {}\n", + cout << fmt::format(" {} ({}) [{}]: {}\n", member.role(), member.instance_name(), + member.kind().empty() ? "vm" : member.kind(), instance_status_name(member.instance_status().status())); return ReturnCode::Ok; }; diff --git a/src/client/cli/cmd/llm.cpp b/src/client/cli/cmd/llm.cpp index bf73de6eeb..c6390e7d0b 100644 --- a/src/client/cli/cmd/llm.cpp +++ b/src/client/cli/cmd/llm.cpp @@ -131,6 +131,11 @@ mp::ReturnCodeVariant cmd::Llm::run(mp::ArgParser* parser) request.set_ctx_size(load_params.ctx_size()); if (load_params.has_max_tokens()) request.set_max_tokens(load_params.max_tokens()); + if (!intent.isEmpty()) + { + request.set_intent(intent.toStdString()); + request.set_intent_role(intent_role.toStdString()); + } AnimatedSpinner spinner{cout}; spinner.start("Loading model "); auto on_success = [this, &spinner](LoadModelReply& reply) -> ReturnCodeVariant { @@ -387,6 +392,10 @@ mp::ParseCode cmd::Llm::parse_args(mp::ArgParser* parser) QCommandLineOption n_cpu_moe_opt{"n-cpu-moe", "Keep the first N MoE layers on CPU", "n"}; QCommandLineOption label_opt{"label", "API key label", "label"}; QCommandLineOption instance_opt{"instance", "Bind key to a loaded LLM instance", "instance"}; + QCommandLineOption intent_opt{ + "intent", "Join (or create) this named intent when loading", "intent"}; + QCommandLineOption intent_role_opt{ + "intent-role", "This instance's role within --intent (required if --intent is set)", "role"}; parser->addOption(use_case_opt); parser->addOption(limit_opt); parser->addOption(query_opt); @@ -411,6 +420,8 @@ mp::ParseCode cmd::Llm::parse_args(mp::ArgParser* parser) parser->addOption(n_cpu_moe_opt); parser->addOption(label_opt); parser->addOption(instance_opt); + parser->addOption(intent_opt); + parser->addOption(intent_role_opt); auto status = parser->commandParse(this); if (status != ParseCode::Ok) @@ -488,6 +499,10 @@ mp::ParseCode cmd::Llm::parse_args(mp::ArgParser* parser) key_label = parser->value(label_opt); if (parser->isSet(instance_opt)) key_instance = parser->value(instance_opt); + if (parser->isSet(intent_opt)) + intent = parser->value(intent_opt); + if (parser->isSet(intent_role_opt)) + intent_role = parser->value(intent_role_opt); const QStringList needs_id{"pull", "load", "unload", "delete", "rm"}; if (needs_id.contains(subcommand) && model_id.isEmpty()) @@ -495,5 +510,10 @@ mp::ParseCode cmd::Llm::parse_args(mp::ArgParser* parser) cerr << "Missing model id\n"; return ParseCode::CommandLineError; } + if (subcommand == "load" && !intent.isEmpty() && intent_role.isEmpty()) + { + cerr << "--intent requires --intent-role\n"; + return ParseCode::CommandLineError; + } return ParseCode::Ok; } diff --git a/src/client/cli/cmd/llm.h b/src/client/cli/cmd/llm.h index b3c0f385af..d5a13cc76d 100644 --- a/src/client/cli/cmd/llm.h +++ b/src/client/cli/cmd/llm.h @@ -45,6 +45,8 @@ class Llm final : public Command QString key_label; QString key_id; QString key_instance; + QString intent; + QString intent_role; int limit{10}; int ctx_size{4096}; int max_tokens{0}; diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 67db273944..506ad8c781 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -8,6 +8,7 @@ import 'package:fpdart/fpdart.dart' hide State; import '../confirmation_dialog.dart'; import '../downloads/download_manager.dart'; import '../ffi.dart'; +import '../intents/intents_screen.dart'; import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../overview/recent_activity.dart'; @@ -682,6 +683,7 @@ class _LaunchFormState extends ConsumerState { final selectedIntent = _selectedIntent; final bool started; + final String successSidebarKey; if (selectedIntent == null) { started = await initiateLaunchFlow( context, @@ -690,10 +692,15 @@ class _LaunchFormState extends ConsumerState { mountRequests: mountRequests.map((r) => r.deepCopy()).toList(), os: imageInfo.os, ); - } else if (selectedIntent == _createNewIntentValue) { - started = await _launchIntoIntent(newIntentName: _newIntentName.trim()); + successSidebarKey = elpVm(launchRequest.instanceName).sidebarKey; } else { - started = await _launchIntoIntent(existingIntentName: selectedIntent); + // The daemon names an intent member "-" itself, ignoring + // whatever name this form's own instanceName field carries, so there's + // no single instance page to jump to here — go to the intent instead. + started = selectedIntent == _createNewIntentValue + ? await _launchIntoIntent(newIntentName: _newIntentName.trim()) + : await _launchIntoIntent(existingIntentName: selectedIntent); + successSidebarKey = IntentsScreen.sidebarKey; } if (!started || !mounted) return; @@ -702,9 +709,7 @@ class _LaunchFormState extends ConsumerState { if (!configureNext) { Scaffold.of(context).closeEndDrawer(); - ref - .read(sidebarKeyProvider.notifier) - .set(elpVm(launchRequest.instanceName).sidebarKey); + ref.read(sidebarKeyProvider.notifier).set(successSidebarKey); } } @@ -749,27 +754,38 @@ class _LaunchFormState extends ConsumerState { } final grpcClient = ref.read(grpcClientProvider); + final role = _intentRole.trim(); final String intentName; - String replyMessage; + final Future op; if (newIntentName != null) { intentName = newIntentName; - final reply = await grpcClient.intentCreate( + op = grpcClient.intentCreate( IntentCreateRequest(name: intentName, members: [member]), ); - replyMessage = reply?.replyMessage ?? ''; } else { intentName = existingIntentName!; - final reply = await grpcClient.intentAddMember( + op = grpcClient.intentAddMember( IntentAddMemberRequest(name: intentName, members: [member]), ); - replyMessage = reply?.replyMessage ?? ''; } - ref.invalidate(intentsStreamProvider); - ref.read(recentActivityProvider.notifier).record( - title: 'Added ${_intentRole.trim()} to intent $intentName', - detail: replyMessage, + // addOperation shows a "starting/succeeded/failed" notification (the + // form itself only shows _intentError inline, which is easy to miss), + // and records the outcome to recent activity. + ref.read(notificationsProvider.notifier).addOperation( + op, + loading: 'Adding $role to intent $intentName…', + onSuccess: (reply) { + final message = reply?.replyMessage as String?; + return message?.isNotEmpty == true + ? message! + : 'Added $role to intent $intentName'; + }, + onError: (error) => '$error', ); + await op; + + ref.invalidate(intentsStreamProvider); return true; } catch (error) { if (!mounted) return false; diff --git a/src/client/gui/lib/intents/intents_screen.dart b/src/client/gui/lib/intents/intents_screen.dart index f5f5ea8c13..6eb570c908 100644 --- a/src/client/gui/lib/intents/intents_screen.dart +++ b/src/client/gui/lib/intents/intents_screen.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../confirmation_dialog.dart'; +import '../l10n/app_localizations.dart'; import '../layout/compact_layout.dart'; +import '../llm/llm_id.dart'; import '../page_surface.dart'; import '../providers.dart'; import '../sidebar.dart'; @@ -22,6 +24,7 @@ class IntentsScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final intentsAsync = ref.watch(intentsStreamProvider); final onSurface = Theme.of(context).colorScheme.onSurface; + final l10n = AppLocalizations.of(context)!; return Scaffold( body: PageSurface( @@ -36,6 +39,12 @@ class IntentsScreen extends ConsumerWidget { style: TextStyle(fontSize: 37, fontWeight: FontWeight.w300), ), ), + IconButton( + tooltip: l10n.catalogueRefresh, + onPressed: () => ref.invalidate(intentsStreamProvider), + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 8), LaunchPadButton.primary( onPressed: () => showCreateIntentDialog(context, ref), child: const Text('New intent'), @@ -127,13 +136,19 @@ class _IntentCard extends ConsumerWidget { ), ) else - ...intent.members.map( - (member) => Padding( + ...intent.members.map((member) { + final isLlm = member.kind == 'llm'; + return Padding( padding: const EdgeInsets.only(top: 8), child: InkWell( - onTap: () => ref - .read(sidebarKeyProvider.notifier) - .set(elpVm(member.instanceName).sidebarKey), + onTap: () => ref.read(sidebarKeyProvider.notifier).set( + isLlm + ? LlmInstanceId( + instanceId: member.instanceName, + modelId: '', + ).sidebarKey + : elpVm(member.instanceName).sidebarKey, + ), child: Row( children: [ VmStatusIcon( @@ -145,14 +160,14 @@ class _IntentCard extends ConsumerWidget { style: const TextStyle(fontWeight: FontWeight.w500)), const SizedBox(width: 8), Text( - '(${member.instanceName})', + isLlm ? '(LLM: ${member.instanceName})' : '(${member.instanceName})', style: TextStyle(color: onSurface.withValues(alpha: 0.6)), ), ], ), ), - ), - ), + ); + }), ], ), ), @@ -163,19 +178,27 @@ class _IntentCard extends ConsumerWidget { class _MemberFields { _MemberFields() : roleController = TextEditingController(), - imageController = TextEditingController(); + imageController = TextEditingController(), + modelIdController = TextEditingController(); final TextEditingController roleController; final TextEditingController imageController; + // When set, this member is an LLM session (loaded the same as `elp llm + // load`) instead of a VM instance; imageController is then ignored. + final TextEditingController modelIdController; void dispose() { roleController.dispose(); imageController.dispose(); + modelIdController.dispose(); } IntentMemberRequest? toRequest() { final role = roleController.text.trim(); if (role.isEmpty) return null; + final modelId = modelIdController.text.trim(); + if (modelId.isNotEmpty) + return IntentMemberRequest(role: role, modelId: modelId); return IntentMemberRequest( role: role, image: imageController.text.trim(), @@ -189,33 +212,48 @@ Widget _memberFieldsRow( }) { return Padding( padding: const EdgeInsets.only(bottom: 8), - child: Row( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: TextFormField( - controller: fields.roleController, - decoration: const InputDecoration( - labelText: 'Role', - hintText: 'e.g. redis', + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: fields.roleController, + decoration: const InputDecoration( + labelText: 'Role', + hintText: 'e.g. redis', + ), + ), ), - ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: fields.imageController, + decoration: const InputDecoration( + labelText: 'Image (optional)', + hintText: 'blank = use "redis"/"postgres" template', + ), + ), + ), + if (onRemove != null) + IconButton( + icon: const Icon(Icons.close), + onPressed: onRemove, + ), + ], ), - const SizedBox(width: 12), - Expanded( + Padding( + padding: const EdgeInsets.only(top: 4), child: TextFormField( - controller: fields.imageController, + controller: fields.modelIdController, decoration: const InputDecoration( - labelText: 'Image (optional)', - hintText: 'blank = use "redis"/"postgres" template', + labelText: 'Model ID (optional, for an LLM member)', + hintText: 'e.g. llama-3.1-8b-instruct; ignores Image above when set', ), ), ), - if (onRemove != null) - IconButton( - icon: const Icon(Icons.close), - onPressed: onRemove, - ), ], ), ); diff --git a/src/client/gui/lib/llm/instances/llm_instance_headers.dart b/src/client/gui/lib/llm/instances/llm_instance_headers.dart index a9963c54a6..9ce41b69c0 100644 --- a/src/client/gui/lib/llm/instances/llm_instance_headers.dart +++ b/src/client/gui/lib/llm/instances/llm_instance_headers.dart @@ -62,6 +62,17 @@ final llmInstanceHeaders = >[ minWidth: 120, cellBuilder: (m) => _LlmCapabilityCell(model: m), ), + TableHeader( + name: 'INTENT', + childBuilder: (_) => TableHeader.defaultHeaderBuilder('Intent'), + width: 140, + minWidth: 100, + sortKey: (m) => m.intent, + cellBuilder: (m) => Text( + m.intent.isEmpty ? '—' : '${m.intent} · ${m.intentRole}', + overflow: TextOverflow.ellipsis, + ), + ), TableHeader( name: 'BACKEND', childBuilder: _l10nHeader((l10n) => l10n.llmTableColumnBackend), @@ -148,7 +159,8 @@ class SelectAllLlmCheckbox extends ConsumerWidget { final q = search.toLowerCase(); return m.modelId.toLowerCase().contains(q) || m.openaiId.toLowerCase().contains(q) || - m.backend.toLowerCase().contains(q); + m.backend.toLowerCase().contains(q) || + m.intent.toLowerCase().contains(q); }) .map((m) => m.instanceId) .toList() ?? diff --git a/src/client/gui/lib/llm/instances/llm_instances_screen.dart b/src/client/gui/lib/llm/instances/llm_instances_screen.dart index 634b948ecc..9bf2331382 100644 --- a/src/client/gui/lib/llm/instances/llm_instances_screen.dart +++ b/src/client/gui/lib/llm/instances/llm_instances_screen.dart @@ -115,7 +115,8 @@ class _LlmInstancesBody extends StatelessWidget { final q = search.toLowerCase(); return m.modelId.toLowerCase().contains(q) || m.openaiId.toLowerCase().contains(q) || - m.backend.toLowerCase().contains(q); + m.backend.toLowerCase().contains(q) || + m.intent.toLowerCase().contains(q); }).toList(growable: false); return Column( diff --git a/src/client/gui/lib/llm/llm_load.dart b/src/client/gui/lib/llm/llm_load.dart index c20dd0a90d..b03815a17c 100644 --- a/src/client/gui/lib/llm/llm_load.dart +++ b/src/client/gui/lib/llm/llm_load.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:grpc/grpc.dart'; import '../brand.dart'; +import '../dropdown.dart'; import '../l10n/app_localizations.dart'; import '../layout/compact_layout.dart'; import '../notifications.dart'; @@ -25,6 +26,24 @@ const _inferenceBackendIds = { if (enableMlxBackend) 'mlx', }; +/// Sentinel dropdown value for "create a new intent" (mirrors the private +/// constant of the same name in launch_form.dart/service_deploy.dart — a +/// leading NUL can never be typed into a text field, so this can't collide +/// with a real intent name). +const _createNewIntentValue = '\u0000__create_new_intent__'; + +/// Result of the (separate, optional) "assign to an intent" prompt shown +/// after the load-settings dialog — kept independent of [LlmLoadForm] since +/// intent membership is a one-off choice for this load, not a per-model +/// default worth persisting via llm_load_prefs.dart the way ctx/GPU/etc are. +class _IntentChoice { + const _IntentChoice({this.intentName, this.isNewIntent = false, this.intentRole = ''}); + + final String? intentName; // null = standalone, no intent + final bool isNewIntent; + final String intentRole; +} + Future loadLlmModel( BuildContext context, WidgetRef ref, { @@ -61,6 +80,10 @@ Future loadLlmModel( ); if (form == null) return; + if (!context.mounted) return; + final intentChoice = await _promptIntentAssignment(context, l10n); + if (intentChoice == null) return; // cancelled + final pending = PendingLlmLoad( id: '$pendingLlmLoadIdPrefix${DateTime.now().microsecondsSinceEpoch}', modelId: modelId, @@ -78,6 +101,9 @@ Future loadLlmModel( quant: quant, hfRepo: hfRepo, form: form, + intentName: intentChoice.intentName, + isNewIntent: intentChoice.isNewIntent, + intentRole: intentChoice.intentRole, ), ); } @@ -88,6 +114,9 @@ Future _completeLlmLoad({ required String quant, required String hfRepo, required LlmLoadForm form, + String? intentName, + bool isNewIntent = false, + String intentRole = '', }) async { try { final client = providerContainer.read(grpcClientProvider); @@ -99,26 +128,59 @@ Future _completeLlmLoad({ )) {} providerContainer.invalidate(loadedModelsProvider); } - await client - .loadModel( - modelId, - quant: quant, - runtime: form.runtime, - ctxSize: form.ctxSize, - maxTokens: form.maxTokens, - params: form.toProto(), - ) - .last; + await writeLlmLoadPrefs( providerContainer.read(sharedPreferencesProvider), modelId, form.toJson(), ); + + if (intentName == null) { + await client + .loadModel( + modelId, + quant: quant, + runtime: form.runtime, + ctxSize: form.ctxSize, + maxTokens: form.maxTokens, + params: form.toProto(), + ) + .last; + providerContainer.read(recentActivityProvider.notifier).record( + title: 'Loaded $modelId', + detail: form.runtime, + ); + } else { + final member = IntentMemberRequest( + role: intentRole, + modelId: modelId, + quant: quant, + runtime: form.runtime, + ctxSize: form.ctxSize, + maxTokens: form.maxTokens, + ); + final Future op = isNewIntent + ? client.intentCreate( + IntentCreateRequest(name: intentName, members: [member]), + ) + : client.intentAddMember( + IntentAddMemberRequest(name: intentName, members: [member]), + ); + providerContainer.read(notificationsProvider.notifier).addOperation( + op, + loading: 'Adding $modelId to intent $intentName…', + onSuccess: (reply) { + final message = reply?.replyMessage as String?; + return message?.isNotEmpty == true + ? message! + : 'Added $modelId to intent $intentName'; + }, + onError: (error) => '$error', + ); + await op; + providerContainer.invalidate(intentsStreamProvider); + } providerContainer.invalidate(loadedModelsProvider); - providerContainer.read(recentActivityProvider.notifier).record( - title: 'Loaded $modelId', - detail: form.runtime, - ); } catch (e) { final message = e is GrpcError ? (e.message ?? '$e') : '$e'; providerContainer.read(notificationsProvider.notifier).addError(message); @@ -551,6 +613,125 @@ class _LoadSettingsDialogState extends State<_LoadSettingsDialog> { } } +/// Separate, optional follow-up to the load-settings dialog: assign the +/// about-to-load session to an intent (existing, or a brand new one), the +/// same picker pattern used in launch_form.dart/service_deploy.dart. Kept as +/// its own step rather than folded into [_LoadSettingsDialog] so this file's +/// merge with upstream's load-settings rework doesn't need to touch that +/// dialog/its tests at all. Returns null if the user cancels outright. +Future<_IntentChoice?> _promptIntentAssignment( + BuildContext context, + AppLocalizations l10n, +) async { + String? selectedIntent; + final newIntentNameController = TextEditingController(); + final intentRoleController = TextEditingController(); + String? error; + + final result = await showDialog<_IntentChoice>( + context: context, + barrierColor: Brand.barrier, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setState) => Consumer( + builder: (ctx, ref, _) { + final intentNames = ref.watch(intentNamesProvider); + return AlertDialog( + title: const Text('Assign to an intent (optional)'), + content: SizedBox( + width: CompactLayout.dialogWidth(ctx, 440), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Dropdown( + label: 'Intent', + width: 360, + value: selectedIntent, + onChanged: (value) => setState(() { + selectedIntent = value; + error = null; + }), + items: { + null: 'None (standalone instance)', + _createNewIntentValue: '+ Create new intent...', + for (final name in intentNames) name: name, + }, + ), + if (selectedIntent == _createNewIntentValue) ...[ + const SizedBox(height: 12), + TextField( + controller: newIntentNameController, + decoration: const InputDecoration( + labelText: 'New intent name', + hintText: 'e.g. test-app-1', + ), + ), + ], + if (selectedIntent != null) ...[ + const SizedBox(height: 12), + TextField( + controller: intentRoleController, + decoration: const InputDecoration( + labelText: 'Role in intent', + hintText: 'e.g. chat', + helperText: + 'What this model is within the intent (e.g. "chat").', + ), + ), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + error!, + style: TextStyle(color: Theme.of(ctx).colorScheme.error), + ), + ], + ], + ), + ), + ), + actions: [ + LaunchPadButton.secondary( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.commonCancel), + ), + LaunchPadButton.primary( + onPressed: () { + final isNewIntent = selectedIntent == _createNewIntentValue; + if (selectedIntent != null && intentRoleController.text.trim().isEmpty) { + setState(() => error = 'Please provide a role for this member.'); + return; + } + if (isNewIntent && newIntentNameController.text.trim().isEmpty) { + setState(() => error = 'Please provide a name for the new intent.'); + return; + } + Navigator.pop( + ctx, + _IntentChoice( + intentName: selectedIntent == null + ? null + : (isNewIntent ? newIntentNameController.text.trim() : selectedIntent), + isNewIntent: isNewIntent, + intentRole: intentRoleController.text.trim(), + ), + ); + }, + child: Text(l10n.modelsLoad), + ), + ], + ); + }, + ), + ), + ); + + newIntentNameController.dispose(); + intentRoleController.dispose(); + return result; +} + String _runtimeLabel(AppLocalizations l10n, String id, String name) { if (name.isNotEmpty) return name; return switch (id) { diff --git a/src/client/gui/lib/services/service_deploy.dart b/src/client/gui/lib/services/service_deploy.dart index 6d1e8da2b7..456e6cfb05 100644 --- a/src/client/gui/lib/services/service_deploy.dart +++ b/src/client/gui/lib/services/service_deploy.dart @@ -9,9 +9,10 @@ import '../catalogue/launch_form.dart'; import '../cloud_init/cloud_init_store.dart'; import '../dropdown.dart'; import '../ffi.dart'; +import '../intents/intents_screen.dart'; import '../l10n/app_localizations.dart'; import '../layout/compact_layout.dart'; -import '../overview/recent_activity.dart'; +import '../notifications.dart'; import '../providers.dart'; import '../sidebar.dart'; import '../widgets/launchpad_button.dart'; @@ -306,6 +307,7 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { final selectedIntent = _selectedIntent; final bool started; + final String successSidebarKey; if (selectedIntent == null) { started = await initiateLaunchFlow( context, @@ -316,15 +318,20 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { confirmLargeDisk: false, successSidebarKey: destination, ); - } else if (selectedIntent == _createNewIntentValue) { - started = await _deployIntoIntent(newIntentName: _newIntentName.trim()); + successSidebarKey = destination; } else { - started = await _deployIntoIntent(existingIntentName: selectedIntent); + // The daemon names an intent member "-" itself, ignoring + // _request.instanceName, so there's no single service page to jump to + // here — go to the intent instead. + started = selectedIntent == _createNewIntentValue + ? await _deployIntoIntent(newIntentName: _newIntentName.trim()) + : await _deployIntoIntent(existingIntentName: selectedIntent); + successSidebarKey = IntentsScreen.sidebarKey; } if (!started || !mounted) return; navigator.pop(); - ref.read(sidebarKeyProvider.notifier).set(destination); + ref.read(sidebarKeyProvider.notifier).set(successSidebarKey); } /// Deploys this service as a member of an intent — either a brand new one @@ -364,27 +371,35 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { } final grpcClient = ref.read(grpcClientProvider); + final role = _intentRole.trim(); final String intentName; - String replyMessage; + final Future op; if (newIntentName != null) { intentName = newIntentName; - final reply = await grpcClient.intentCreate( + op = grpcClient.intentCreate( IntentCreateRequest(name: intentName, members: [member]), ); - replyMessage = reply?.replyMessage ?? ''; } else { intentName = existingIntentName!; - final reply = await grpcClient.intentAddMember( + op = grpcClient.intentAddMember( IntentAddMemberRequest(name: intentName, members: [member]), ); - replyMessage = reply?.replyMessage ?? ''; } - ref.invalidate(intentsStreamProvider); - ref.read(recentActivityProvider.notifier).record( - title: 'Added ${_intentRole.trim()} to intent $intentName', - detail: replyMessage, + ref.read(notificationsProvider.notifier).addOperation( + op, + loading: 'Adding $role to intent $intentName…', + onSuccess: (reply) { + final message = reply?.replyMessage as String?; + return message?.isNotEmpty == true + ? message! + : 'Added $role to intent $intentName'; + }, + onError: (error) => '$error', ); + await op; + + ref.invalidate(intentsStreamProvider); return true; } catch (error) { if (!mounted) return false; diff --git a/src/client/gui/lib/vm_table/vms.dart b/src/client/gui/lib/vm_table/vms.dart index 412d570e20..3cfbe5b897 100644 --- a/src/client/gui/lib/vm_table/vms.dart +++ b/src/client/gui/lib/vm_table/vms.dart @@ -93,6 +93,12 @@ class SelectedVmsNotifier extends Notifier> { isSelected ? set.add(id) : set.remove(id); }); } + + void toggleAll(Iterable ids, bool isSelected) { + state = state.rebuild((set) { + isSelected ? set.addAll(ids) : set.removeAll(ids); + }); + } } class Vms extends ConsumerWidget { @@ -280,7 +286,25 @@ class _GroupedByIntentTables extends StatelessWidget { SizedBox( height: _headerRowHeight + groups[key]!.length * 50, child: Table( - headers: headers, + // "Select all" in the checkbox column must only select this + // group's own rows, not every instance across every intent — + // substitute a group-scoped checkbox for the shared header + // (whose default childBuilder selects across all instances). + headers: [ + for (final h in headers) + if (h.name == 'checkbox') + TableHeader( + name: h.name, + childBuilder: (_) => _GroupSelectAllCheckbox( + ids: groups[key]!.map((info) => info.id).toList(), + ), + width: h.width, + minWidth: h.minWidth, + cellBuilder: h.cellBuilder, + ) + else + h, + ], data: groups[key]!, finalRow: const [], isSelected: isSelected, @@ -292,3 +316,28 @@ class _GroupedByIntentTables extends StatelessWidget { ); } } + +/// Like [SelectAllCheckbox], but scoped to one intent group's own instances +/// instead of every instance in the (unfiltered) list. +class _GroupSelectAllCheckbox extends ConsumerWidget { + const _GroupSelectAllCheckbox({required this.ids}); + + final List ids; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedVms = ref.watch(selectedVmsProvider); + final selectedCount = ids.where(selectedVms.contains).length; + final allSelected = ids.isNotEmpty && selectedCount == ids.length; + + return Center( + child: Checkbox( + tristate: true, + value: selectedCount == 0 ? false : (allSelected ? true : null), + onChanged: (checked) => ref + .read(selectedVmsProvider.notifier) + .toggleAll(ids, checked ?? false), + ), + ); + } +} diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index a678aaa51d..f2b86ad7ae 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -3184,6 +3184,55 @@ class IntentMemberLaunchSink grpc::ServerReaderWriterInterface* outer; }; +// Same bridging idea as IntentMemberLaunchSink, but for a member that's an LLM +// session (load_model) rather than a VM. Also captures the instance_id off any +// reply that carries one into `captured_instance_id`, since (unlike a VM's +// instance name, which the caller picks up front) an LLM session's instance_id +// is only known once load_model_impl generates it. +template +class IntentMemberLlmLoadSink + : public grpc::ServerReaderWriterInterface +{ +public: + IntentMemberLlmLoadSink(grpc::ServerReaderWriterInterface* outer, + std::shared_ptr captured_instance_id) + : outer{outer}, captured_instance_id{std::move(captured_instance_id)} + { + } + + void SendInitialMetadata() override + { + } + + bool NextMessageSize(uint32_t* sz) override + { + *sz = 0; + return false; + } + + bool Read(mp::LoadModelRequest*) override + { + return false; + } + + bool Write(const mp::LoadModelReply& reply, grpc::WriteOptions) override + { + if (!reply.instance_id().empty()) + *captured_instance_id = reply.instance_id(); + if (!reply.log_line().empty()) + { + OuterReply forwarded; + forwarded.set_log_line(reply.log_line()); + outer->Write(forwarded); + } + return true; + } + +private: + grpc::ServerReaderWriterInterface* outer; + std::shared_ptr captured_instance_id; +}; + // A one-shot DaemonRpcContext for a single intent member's internal launch: // forwards the eventual status to `on_done` instead of fulfilling a promise // someone blocks on. Nothing in intent_create waits synchronously for this, @@ -3229,6 +3278,9 @@ std::optional> build_intent_member_launch_request std::vector launch_requests; for (const auto& member : members) { + if (!member.model_id().empty()) + continue; // handled as an LLM member by build_intent_member_load_requests instead + const auto& role = member.role(); if (role.empty()) { @@ -3270,6 +3322,44 @@ std::optional> build_intent_member_launch_request } return launch_requests; } + +// Same purpose as build_intent_member_launch_requests, but for members that +// name an LLM model (IntentMemberRequest::model_id) instead of a VM image. +std::optional> build_intent_member_load_requests( + const std::string& intent_name, + const google::protobuf::RepeatedPtrField& members, + grpc::Status& error) +{ + std::vector load_requests; + for (const auto& member : members) + { + if (member.model_id().empty()) + continue; // handled as a VM member by build_intent_member_launch_requests instead + + const auto& role = member.role(); + if (role.empty()) + { + error = {grpc::StatusCode::INVALID_ARGUMENT, "Each intent member needs a role", ""}; + return std::nullopt; + } + + mp::LoadModelRequest lr; + lr.set_model_id(member.model_id()); + lr.set_intent(intent_name); + lr.set_intent_role(role); + if (!member.quant().empty()) + lr.set_quant(member.quant()); + if (member.ctx_size() > 0) + lr.set_ctx_size(member.ctx_size()); + if (!member.runtime().empty()) + lr.set_runtime(member.runtime()); + if (member.max_tokens() > 0) + lr.set_max_tokens(member.max_tokens()); + + load_requests.push_back(std::move(lr)); + } + return load_requests; +} } // namespace void mp::Daemon::launch_intent_members( @@ -3302,7 +3392,8 @@ void mp::Daemon::launch_intent_members( // Members already launched are left running (not rolled back in v1). return on_failure(status); - launched->push_back({role, (*launch_requests)[*member_index].instance_name()}); + launched->push_back( + {role, (*launch_requests)[*member_index].instance_name(), "vm"}); ++(*member_index); (*launch_next)(); }); @@ -3321,6 +3412,76 @@ void mp::Daemon::launch_intent_members( (*launch_next)(); } +// Same chaining shape as launch_intent_members, but for members that are LLM +// sessions (load_model) rather than VMs. load_model runs on llm_dispatcher's +// own thread and its context->set_value can fire from an arbitrary worker-pool +// thread (see LlmDispatcher::run_async), so — unlike launch_intent_members, +// whose create_vm completion is already marshaled back by its QFutureWatcher — +// each member's completion is explicitly re-marshaled onto Daemon's own thread +// before touching any Daemon state. +void mp::Daemon::launch_intent_llm_members( + std::shared_ptr> load_requests, + std::shared_ptr> member_sink, + std::shared_ptr captured_instance_id, + std::function)> on_all_loaded, + std::function on_failure) +{ + auto loaded = std::make_shared>(); + auto member_index = std::make_shared(0); + auto load_next = std::make_shared>(); + *load_next = [this, + load_requests, + member_sink, + captured_instance_id, + loaded, + member_index, + load_next, + on_all_loaded, + on_failure] { + if (*member_index >= load_requests->size()) + return on_all_loaded(*loaded); + + const auto index = *member_index; + auto role = (*load_requests)[index].intent_role(); + captured_instance_id->clear(); + + auto* member_context = new IntentMemberContext( + [this, captured_instance_id, loaded, member_index, load_next, on_failure, role]( + grpc::Status status) { + QMetaObject::invokeMethod( + this, + [status, role, captured_instance_id, loaded, member_index, load_next, + on_failure] { + if (!status.ok()) + // Members already loaded are left running (not rolled back in v1). + return on_failure(status); + + loaded->push_back({role, *captured_instance_id, "llm"}); + ++(*member_index); + (*load_next)(); + }, + Qt::QueuedConnection); + }); + + if (!llm_dispatcher) + { + delete member_context; + return on_failure({grpc::StatusCode::FAILED_PRECONDITION, + "LLM support is not available on this daemon", + ""}); + } + + QMetaObject::invokeMethod( + llm_dispatcher.get(), + [this, load_requests, index, sink = member_sink.get(), member_context] { + llm_dispatcher->load_model(&(*load_requests)[index], sink, member_context); + }, + Qt::QueuedConnection); + }; + + (*load_next)(); +} + void mp::Daemon::intent_create( const IntentCreateRequest* request, grpc::ServerReaderWriterInterface* server, @@ -3342,29 +3503,50 @@ try auto built = build_intent_member_launch_requests(name, request->members(), build_error); if (!built) return context->set_value(build_error); + auto built_llm = build_intent_member_load_requests(name, request->members(), build_error); + if (!built_llm) + return context->set_value(build_error); auto launch_requests = std::make_shared>(std::move(*built)); std::shared_ptr> sink = std::make_shared>(server); + auto load_requests = std::make_shared>(std::move(*built_llm)); + auto captured_instance_id = std::make_shared(); + std::shared_ptr> llm_sink = + std::make_shared>( + server, captured_instance_id); + launch_intent_members( launch_requests, sink, - [this, server, context, name](std::vector members) { - IntentSpec spec; - spec.name = name; - spec.members = std::move(members); - spec.creation_timestamp = - QDateTime::currentDateTime().toString(Qt::ISODateWithMs).toStdString(); - intents[name] = spec; - persist_intents(); - - IntentCreateReply reply; - reply.set_reply_message(fmt::format("Intent \"{}\" created with {} member(s).", - name, - spec.members.size())); - server->Write(reply); - context->set_value(grpc::Status::OK); + [this, server, context, name, load_requests, llm_sink, captured_instance_id]( + std::vector vm_members) { + launch_intent_llm_members( + load_requests, + llm_sink, + captured_instance_id, + [this, server, context, name, vm_members = std::move(vm_members)]( + std::vector llm_members) mutable { + IntentSpec spec; + spec.name = name; + spec.members = std::move(vm_members); + spec.members.insert(spec.members.end(), + std::make_move_iterator(llm_members.begin()), + std::make_move_iterator(llm_members.end())); + spec.creation_timestamp = + QDateTime::currentDateTime().toString(Qt::ISODateWithMs).toStdString(); + intents[name] = spec; + persist_intents(); + + IntentCreateReply reply; + reply.set_reply_message(fmt::format("Intent \"{}\" created with {} member(s).", + name, + spec.members.size())); + server->Write(reply); + context->set_value(grpc::Status::OK); + }, + [context](grpc::Status status) { context->set_value(status); }); }, [context](grpc::Status status) { context->set_value(status); }); } @@ -3391,38 +3573,62 @@ try auto built = build_intent_member_launch_requests(name, request->members(), build_error); if (!built) return context->set_value(build_error); + auto built_llm = build_intent_member_load_requests(name, request->members(), build_error); + if (!built_llm) + return context->set_value(build_error); auto launch_requests = std::make_shared>(std::move(*built)); std::shared_ptr> sink = std::make_shared>( server); + auto load_requests = std::make_shared>(std::move(*built_llm)); + auto captured_instance_id = std::make_shared(); + std::shared_ptr> llm_sink = + std::make_shared>( + server, captured_instance_id); + launch_intent_members( launch_requests, sink, - [this, server, context, name](std::vector members) { - // launch_intent_members runs asynchronously; guard against the intent having - // been deleted (by a concurrent intent_delete) while these members were still - // being launched, rather than silently reviving it with intents[name]. - auto it = intents.find(name); - if (it == intents.end()) - return context->set_value( - {grpc::StatusCode::ABORTED, - fmt::format("Intent \"{}\" was deleted while members were being added; " - "the new instance(s) are still running but untracked", - name), - ""}); - - const auto added = members.size(); - for (auto& member : members) - it->second.members.push_back(std::move(member)); - persist_intents(); - - IntentAddMemberReply reply; - reply.set_reply_message( - fmt::format("Added {} member(s) to intent \"{}\".", added, name)); - server->Write(reply); - context->set_value(grpc::Status::OK); + [this, server, context, name, load_requests, llm_sink, captured_instance_id]( + std::vector vm_members) { + launch_intent_llm_members( + load_requests, + llm_sink, + captured_instance_id, + [this, server, context, name, vm_members = std::move(vm_members)]( + std::vector llm_members) mutable { + // launch_intent_members/launch_intent_llm_members run asynchronously; + // guard against the intent having been deleted (by a concurrent + // intent_delete) while these members were still being added, rather + // than silently reviving it with intents[name]. + auto it = intents.find(name); + if (it == intents.end()) + return context->set_value( + {grpc::StatusCode::ABORTED, + fmt::format( + "Intent \"{}\" was deleted while members were being added; " + "the new instance(s) are still running but untracked", + name), + ""}); + + auto members = std::move(vm_members); + members.insert(members.end(), + std::make_move_iterator(llm_members.begin()), + std::make_move_iterator(llm_members.end())); + const auto added = members.size(); + for (auto& member : members) + it->second.members.push_back(std::move(member)); + persist_intents(); + + IntentAddMemberReply reply; + reply.set_reply_message( + fmt::format("Added {} member(s) to intent \"{}\".", added, name)); + server->Write(reply); + context->set_value(grpc::Status::OK); + }, + [context](grpc::Status status) { context->set_value(status); }); }, [context](grpc::Status status) { context->set_value(status); }); } @@ -3431,6 +3637,18 @@ catch (const std::exception& e) context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); } +mp::InstanceStatus::Status mp::Daemon::intent_member_status(const IntentSpec::Member& member) const +{ + if (member.kind == "llm") + return llm_dispatcher && llm_dispatcher->has_instance(member.instance_name) + ? mp::InstanceStatus::RUNNING + : mp::InstanceStatus::DELETED; + + auto vm_it = operative_instances.find(member.instance_name); + return vm_it == operative_instances.end() ? mp::InstanceStatus::DELETED + : grpc_instance_status_for(vm_it->second->current_state()); +} + void mp::Daemon::intent_list( const IntentListRequest*, grpc::ServerReaderWriterInterface* server, @@ -3450,12 +3668,8 @@ try auto* proto_member = info->add_members(); proto_member->set_role(member.role); proto_member->set_instance_name(member.instance_name); - - auto vm_it = operative_instances.find(member.instance_name); - proto_member->mutable_instance_status()->set_status( - vm_it == operative_instances.end() - ? mp::InstanceStatus::DELETED - : grpc_instance_status_for(vm_it->second->current_state())); + proto_member->set_kind(member.kind); + proto_member->mutable_instance_status()->set_status(intent_member_status(member)); } } @@ -3491,11 +3705,8 @@ try auto* proto_member = info->add_members(); proto_member->set_role(member.role); proto_member->set_instance_name(member.instance_name); - - auto vm_it = operative_instances.find(member.instance_name); - proto_member->mutable_instance_status()->set_status( - vm_it == operative_instances.end() ? mp::InstanceStatus::DELETED - : grpc_instance_status_for(vm_it->second->current_state())); + proto_member->set_kind(member.kind); + proto_member->mutable_instance_status()->set_status(intent_member_status(member)); } server->Write(response); @@ -3521,8 +3732,15 @@ try const auto purge = request->purge(); auto instances_dirty = false; + std::vector llm_instance_ids; for (const auto& member : it->second.members) { + if (member.kind == "llm") + { + llm_instance_ids.push_back(member.instance_name); + continue; + } + auto vm_it = operative_instances.find(member.instance_name); if (vm_it == operative_instances.end()) continue; @@ -3533,6 +3751,8 @@ try if (instances_dirty) persist_instances(); + if (llm_dispatcher) + llm_dispatcher->unload_instances_blocking(llm_instance_ids); intents.erase(it); persist_intents(); diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index 3920c7aa37..e6f85dac1c 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -239,6 +239,22 @@ public slots: std::shared_ptr> member_sink, std::function)> on_all_launched, std::function on_failure); + + // Same idea, for members that are LLM sessions (load_model) instead of VMs. + // captured_instance_id is a scratch slot the member_sink writes each member's + // generated instance_id into (an LLM session's id isn't known up front, unlike + // a VM's instance name). + void launch_intent_llm_members( + std::shared_ptr> load_requests, + std::shared_ptr> + member_sink, + std::shared_ptr captured_instance_id, + std::function)> on_all_loaded, + std::function on_failure); + + // Current status of one intent member for intent_list/intent_info, dispatching + // on member.kind ("vm" -> operative_instances, "llm" -> llm_dispatcher). + InstanceStatus::Status intent_member_status(const IntentSpec::Member& member) const; bool delete_vm(InstanceTable::iterator vm_it, bool purge, DeleteReply& response); grpc::Status reboot_vm(VirtualMachine& vm); grpc::Status shutdown_vm(VirtualMachine& vm, const std::chrono::milliseconds delay); diff --git a/src/daemon/llm_dispatcher.cpp b/src/daemon/llm_dispatcher.cpp index 86fb4cdb83..cb57103baf 100644 --- a/src/daemon/llm_dispatcher.cpp +++ b/src/daemon/llm_dispatcher.cpp @@ -83,6 +83,11 @@ void mp::LlmDispatcher::unload_instances_blocking(const std::vector Qt::BlockingQueuedConnection); } +bool mp::LlmDispatcher::has_instance(const std::string& instance_id) const +{ + return llm_service && llm_service->has_instance(instance_id); +} + template void mp::LlmDispatcher::run_sync(DaemonRpcContext* context, Work&& work) { diff --git a/src/daemon/llm_dispatcher.h b/src/daemon/llm_dispatcher.h index d6b740c0d5..ac4d7cee03 100644 --- a/src/daemon/llm_dispatcher.h +++ b/src/daemon/llm_dispatcher.h @@ -48,6 +48,9 @@ class LlmDispatcher : public QObject ~LlmDispatcher() override; void unload_instances_blocking(const std::vector& instance_ids); + // Fast, mutex-protected lookup safe to call from any thread (unlike the slots + // above, which must run on this object's own thread). + bool has_instance(const std::string& instance_id) const; public slots: void shutdown(); diff --git a/src/llm/llm_service.cpp b/src/llm/llm_service.cpp index d807fe1ede..44c44066bc 100644 --- a/src/llm/llm_service.cpp +++ b/src/llm/llm_service.cpp @@ -174,6 +174,8 @@ void mp::LlmService::restore_claims() session.ctx_size = 4096; if (obj.value("params").isObject()) session.params = llm_load_params_from_json(obj.value("params").toObject()); + session.intent = obj.value("intent").toString().toStdString(); + session.intent_role = obj.value("intent_role").toString().toStdString(); if (session.instance_id.empty()) continue; if (!session_is_live(session) && live_cmds.find(session.pid) == live_cmds.end()) @@ -676,6 +678,7 @@ void mp::LlmService::load_model_impl( // OOM on a laptop. Reuse the live instance instead. { std::optional reused; + auto persist_reuse = false; { std::lock_guard lock{mutex}; for (auto& [_, existing] : sessions) @@ -683,6 +686,12 @@ void mp::LlmService::load_model_impl( if (existing.model_id != model_id || !session_is_live(existing)) continue; existing.last_used = std::chrono::steady_clock::now(); + if (!request->intent().empty() && existing.intent.empty()) + { + existing.intent = request->intent(); + existing.intent_role = request->intent_role(); + persist_reuse = true; + } LoadModelReply reply; reply.set_instance_id(existing.instance_id); reply.set_model_id(existing.model_id); @@ -696,6 +705,8 @@ void mp::LlmService::load_model_impl( } if (reused) { + if (persist_reuse) + persist_sessions(); log_lifecycle(reused->instance_id(), "info", fmt::format("load reused existing instance for {}", model_id)); @@ -741,6 +752,8 @@ void mp::LlmService::load_model_impl( session.ctx_size = ctx; session.max_tokens = max_tokens; session.params = resolved.echoed; + session.intent = request->intent(); + session.intent_role = request->intent_role(); try { @@ -932,6 +945,8 @@ void mp::LlmService::list_models( info->set_max_tokens(session.max_tokens); info->set_ctx_size(session.ctx_size); *info->mutable_params() = session.params; + info->set_intent(session.intent); + info->set_intent_role(session.intent_role); } for (const auto& art : vault.list()) { @@ -1335,6 +1350,12 @@ bool mp::LlmService::is_loaded(const std::string& model_id) const return false; } +bool mp::LlmService::has_instance(const std::string& instance_id) const +{ + std::lock_guard lock{mutex}; + return sessions.find(instance_id) != sessions.end(); +} + std::optional mp::LlmService::session_by_openai_id(const std::string& openai_id) { std::lock_guard lock{mutex}; @@ -1404,6 +1425,8 @@ void mp::LlmService::persist_sessions() const const auto params = llm_load_params_to_json(session.params); if (!params.isEmpty()) obj["params"] = params; + obj["intent"] = QString::fromStdString(session.intent); + obj["intent_role"] = QString::fromStdString(session.intent_role); array.append(obj); } } diff --git a/src/llm/llm_service.h b/src/llm/llm_service.h index 4e7e8b0e6a..c3523f81d6 100644 --- a/src/llm/llm_service.h +++ b/src/llm/llm_service.h @@ -62,6 +62,8 @@ struct LoadedSession int ctx_size{4096}; int max_tokens{0}; // 0 = unlimited LlmLoadParams params; + std::string intent; + std::string intent_role; MemorySize memory; std::unique_ptr process; std::unique_ptr runner_thread; @@ -126,6 +128,7 @@ class LlmService : public QObject void unload_all_for_model(const std::string& model_id); std::optional session_by_openai_id(const std::string& openai_id); bool is_loaded(const std::string& model_id) const; + bool has_instance(const std::string& instance_id) const; private: enum class BackendKind diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index 3012c6f993..8ed44b3502 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -594,6 +594,16 @@ message IntentMemberRequest { // LaunchRequest's own service_id so a service deployed into an intent // is still recognized as a service instance in the GUI. string service_id = 7; + // When set, this member is an LLM session rather than a VM: the daemon + // calls load_model(model_id) instead of launching a VM, and role/image/ + // cloud_init_user_data/num_cores/mem_size/disk_space/service_id are + // ignored for this member. quant/ctx_size/runtime/max_tokens below mirror + // the matching LoadModelRequest fields and are only used alongside model_id. + string model_id = 8; + string quant = 9; + int32 ctx_size = 10; + string runtime = 11; + int32 max_tokens = 12; } message IntentCreateRequest { @@ -622,6 +632,9 @@ message IntentMember { string role = 1; string instance_name = 2; InstanceStatus instance_status = 3; + // "vm" (default) or "llm": which kind of thing instance_name identifies + // (a launched VM instance name, or an LLM session's instance_id). + string kind = 4; } message IntentInfo { @@ -848,6 +861,10 @@ message LoadModelRequest { // Default/cap for OpenAI max_tokens on this instance. 0 = unlimited. int32 max_tokens = 6; LlmLoadParams params = 7; + // Optional intent (named group) membership, mirroring LaunchRequest's + // own intent/intent_role fields. + string intent = 8; + string intent_role = 9; } message LoadModelReply { @@ -887,6 +904,8 @@ message LoadedModelInfo { // KV / context window passed to the backend (--ctx-size). int32 ctx_size = 10; LlmLoadParams params = 11; + string intent = 12; + string intent_role = 13; } message ListModelsRequest { diff --git a/src/utils/intent_spec.cpp b/src/utils/intent_spec.cpp index 5f05a79128..e1fbb0fffa 100644 --- a/src/utils/intent_spec.cpp +++ b/src/utils/intent_spec.cpp @@ -29,6 +29,7 @@ void mp::tag_invoke(const boost::json::value_from_tag&, members.push_back({ {"role", member.role}, {"instance_name", member.instance_name}, + {"kind", member.kind}, }); json = { @@ -47,7 +48,8 @@ mp::IntentSpec mp::tag_invoke(const boost::json::value_to_tag&, for (const auto& member : json.at("members").as_array()) spec.members.push_back({value_to(member.at("role")), - value_to(member.at("instance_name"))}); + value_to(member.at("instance_name")), + lookup_or(member, "kind", "vm")}); return spec; } From bb5819dfbf94cc511de3f675136b10202ddec3d5 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 18:19:31 +0200 Subject: [PATCH 22/27] Package elp-api as its own systemd service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arch package only ever shipped elpd.service, so elp-api (the REST sidecar the GUI depends on for things like the catalogue's per-service gateway CA fetch at https://127.0.0.1:7777/ca.crt) never started unless run manually — every "Deploy" from the Catalogue failed with a connection-refused error against that port. Add elp-api.service (--insecure-no-auth, matching this project's own macOS LaunchDaemon default) alongside elpd.service: new packaging/archlinux/elp-api.service, wired into PKGBUILD.in's source=()/package() and build-arch.sh's staging copy the same way elpd.service already is. BUILD.linux.md now tells installers to `systemctl enable --now elpd elp-api` (Arch packages don't auto-enable services on install). --- BUILD.linux.md | 14 ++++++++++++-- packaging/archlinux/PKGBUILD.in | 12 +++++++----- packaging/archlinux/elp-api.service | 18 ++++++++++++++++++ scripts/build-arch.sh | 2 ++ 4 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 packaging/archlinux/elp-api.service diff --git a/BUILD.linux.md b/BUILD.linux.md index 90f4bc4fc7..2907c25783 100644 --- a/BUILD.linux.md +++ b/BUILD.linux.md @@ -26,8 +26,18 @@ freshly recreated chroot, reuse those binaries instead of recompiling them. Delete that directory to force a full rebuild from scratch. Install the resulting package with `pacman -U .pkg.tar.zst`; this -installs `elpd`/`elp`/the GUI, a systemd unit (`systemctl enable --now -elpd`), and desktop/icon/completion files. +installs `elpd`/`elp`/the GUI, desktop/icon/completion files, and two +systemd units you need to enable yourself (packages don't auto-enable +services on Arch): + +``` +sudo systemctl enable --now elpd elp-api +``` + +`elp-api` is the REST/gateway sidecar (`https://127.0.0.1:7777`) the GUI +uses for things like the catalogue's per-service gateway CA fetch — without +it running, deploying anything from the Catalogue fails with a connection- +refused error trying to reach `127.0.0.1:7777/ca.crt`. ## Ubuntu / apt-based distributions diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index 5a87e4bc26..7a46dfc58d 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -79,12 +79,12 @@ makedepends=( ) options=('!lto' '!debug') # LTO not worth the build time here; !debug skips shipping vcpkg's # entire buildtree (Qt6/gRPC/QEMU/...) as debug symbols (~5GB) -# elpd.service must be a declared local source (not just a sibling file -# scripts/build-arch.sh copies next to this PKGBUILD): a sandboxed -# extra-x86_64-build chroot only stages the PKGBUILD plus its own +# elpd.service/elp-api.service must be declared local sources (not just +# sibling files scripts/build-arch.sh copies next to this PKGBUILD): a +# sandboxed extra-x86_64-build chroot only stages the PKGBUILD plus its own # source=() entries, nothing else that happens to sit alongside it. -source=("elp::git+@REPO_SOURCE@" "elpd.service") -sha256sums=('SKIP' 'SKIP') +source=("elp::git+@REPO_SOURCE@" "elpd.service" "elp-api.service") +sha256sums=('SKIP' 'SKIP' 'SKIP') pkgver() { cd "${srcdir}/elp" @@ -148,6 +148,8 @@ package() { install -Dm644 "${srcdir}/elpd.service" \ "${pkgdir}/usr/lib/systemd/system/elpd.service" + install -Dm644 "${srcdir}/elp-api.service" \ + "${pkgdir}/usr/lib/systemd/system/elp-api.service" install -Dm644 "${srcdir}/elp/data/elp.gui.desktop" \ "${pkgdir}/usr/share/applications/elp.gui.desktop" install -Dm644 "${srcdir}/elp/data/elp.gui.png" \ diff --git a/packaging/archlinux/elp-api.service b/packaging/archlinux/elp-api.service new file mode 100644 index 0000000000..6fbae1857a --- /dev/null +++ b/packaging/archlinux/elp-api.service @@ -0,0 +1,18 @@ +[Unit] +Description=Electros LaunchPad REST API (gateway CA, OpenAI-compatible endpoints, etc.) +After=network.target elpd.service +Wants=elpd.service + +[Service] +Type=simple +# HTTPS (self-signed cert auto-generated/cached) on the default listen +# address (127.0.0.1 + the VM gateway IP, port 7777 — see +# include/multipass/constants.h's default_api_listen); --insecure-no-auth +# matches this project's macOS LaunchDaemon default, since the GUI and any +# service deployed from the catalogue expect to reach this over plain +# loopback/VM-gateway HTTPS without a bearer token. +ExecStart=/usr/bin/elp-api --insecure-no-auth +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/scripts/build-arch.sh b/scripts/build-arch.sh index 9a3e039480..9ebd004db1 100755 --- a/scripts/build-arch.sh +++ b/scripts/build-arch.sh @@ -9,6 +9,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PKG_TEMPLATE="${ROOT}/packaging/archlinux/PKGBUILD.in" SERVICE_FILE="${ROOT}/packaging/archlinux/elpd.service" +API_SERVICE_FILE="${ROOT}/packaging/archlinux/elp-api.service" SCRATCH_DIR="${ARCH_PKG_SCRATCH_DIR:-${ROOT}/build/archlinux-pkg}" VCPKG_CACHE_DIR="${ARCH_PKG_VCPKG_CACHE:-${ROOT}/build/vcpkg-binary-cache}" SANDBOXED=1 @@ -62,6 +63,7 @@ rendered="${rendered//@REPO_SOURCE@/${repo_source}}" rendered="${rendered//@VCPKG_CACHE_DIR@/${VCPKG_CACHE_DIR}}" printf '%s\n' "${rendered}" > "${SCRATCH_DIR}/PKGBUILD" cp "${SERVICE_FILE}" "${SCRATCH_DIR}/elpd.service" +cp "${API_SERVICE_FILE}" "${SCRATCH_DIR}/elp-api.service" echo "==> Rendered PKGBUILD in ${SCRATCH_DIR} (source: ${repo_source})" echo "==> vcpkg binary cache: ${VCPKG_CACHE_DIR}" From fe7be3dd091f2d5a50d7f675190bd6fe4b510728 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 19:35:51 +0200 Subject: [PATCH 23/27] Add v1 migration feature: elp migrate, mDNS host discovery, GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the migration feature end to end: `elp migrate --to user@host [--copy]` moves a VM instance or a whole intent (VM and/or LLM members) to another elp host. Architecture note (deviates from the original plan): rather than a new daemon-to-daemon "migrate_import" RPC, the source daemon drives the target purely through the target's own local `elp` CLI over SSH. A real daemon-to-daemon gRPC call would need the target's local.passphrase set and a prior `elp authenticate` run (elpd only auto-trusts a client cert over its unix socket, not over TCP) — going over SSH instead reuses the SSH access the user already needs to name a host as a target at all, with no new elp-specific trust setup. Daemon (src/daemon/daemon.cpp, migrate/list_network_hosts/add_known_host/ remove_known_host): - VMSpecs gained image/cloud_init_user_data/remote_name (nothing persisted this before — it was discarded right after launch for every instance), needed to redefine an instance's original spec on a migration target. - Daemon::migrate stops VM members synchronously (same call `elp stop --force` uses), snapshots each member's spec (VMSpecs for VM members, LlmService::instance_info — new — for LLM members), then runs the actual ssh/rsync work on a background thread (QtConcurrent, mirroring create_vm's own async pattern) so a slow transfer can't block the daemon; only the commit step (delete source / persist / update intents) is marshaled back to the daemon's own thread via QFutureWatcher::finished, as usual. - A standalone VM is redefined via `elp launch --cloud-init -`, piping cloud-init through the ssh tunnel's stdin (no remote temp file needed). An intent's members (VM and/or LLM together) are redefined in one combined `elp intent create --instance ... --model ...` call, reusing the intent feature's own existing member-parsing rather than duplicating it — this is also what actually re-registers each member into the target's own intents map (`elp llm load --intent` alone only tags a session, it doesn't touch the registry). Mounts are rsynced to the same absolute host path on the target, then re-attached with `elp mount`. - Mode B ("move volume" fast path) is deliberately deferred; v1 only implements Mode A (redefine + sync data), which is also the only mode that applies to LLM members at all. mDNS host discovery (include/multipass/mdns_service.h, new src/platform/mdns_service*.cpp): elpd advertises itself and browses for peers on "_elp._tcp", behind one MdnsService interface — AvahiThreadedPoll on Linux (new avahi dependency, packaging/archlinux updated), the classic dns_sd.h API on macOS (untested — no macOS access in this session), a permanently-inert no-op elsewhere. A manually-added "known hosts" list (new list_network_hosts/add_known_host/remove_known_host RPCs, persisted like intents) is the always-available fallback for hosts mDNS can't reach (different subnet/VLAN, or avahi-daemon not running). GUI: new Migration Hosts page (known-host CRUD) and a migrate dialog (pick a discovered/known host or type a new one, optional --copy) wired into the instance details page and each intent's card. CLI: new `elp migrate` command. None of this has been build-tested — the mDNS/Avahi and SSH/rsync-over- child-process pieces especially are first-draft, unverifiable-by-me code (no second host or avahi-daemon reachable in this environment) and should be expected to need real debugging once actually run, the same way the Arch PKGBUILD did. Fix avahi pkg-config check: no separate avahi-common.pc on Arch pkg_check_modules(AVAHI REQUIRED IMPORTED_TARGET avahi-client avahi-common) failed during the sandboxed build: Arch's avahi package ships avahi-client.pc but no avahi-common.pc — avahi-client.pc's own Libs: line already includes -lavahi-common, so avahi-common was never a separate pkg-config module to require in the first place. --- BUILD.linux.md | 12 + include/multipass/mdns_service.h | 84 +++ include/multipass/vm_specs.h | 7 + packaging/archlinux/PKGBUILD.in | 4 + src/client/cli/client.cpp | 2 + src/client/cli/cmd/CMakeLists.txt | 1 + src/client/cli/cmd/migrate.cpp | 123 ++++ src/client/cli/cmd/migrate.h | 41 ++ src/client/gui/lib/grpc_client.dart | 23 + .../gui/lib/intents/intents_screen.dart | 6 + src/client/gui/lib/main.dart | 2 + .../gui/lib/migrate/migrate_screen.dart | 388 ++++++++++ src/client/gui/lib/providers.dart | 22 + src/client/gui/lib/sidebar.dart | 11 + .../lib/vm_details/vm_details_general.dart | 7 + src/daemon/daemon.cpp | 697 +++++++++++++++++- src/daemon/daemon.h | 24 + src/daemon/daemon_rpc.cpp | 52 ++ src/daemon/daemon_rpc.h | 26 + src/daemon/llm_dispatcher.cpp | 6 + src/daemon/llm_dispatcher.h | 4 +- src/llm/llm_service.cpp | 23 + src/llm/llm_service.h | 3 + src/platform/CMakeLists.txt | 22 +- src/platform/mdns_service.cpp | 25 + src/platform/mdns_service_linux.cpp | 320 ++++++++ src/platform/mdns_service_macos.cpp | 257 +++++++ src/platform/mdns_service_noop.cpp | 39 + src/rpc/multipass.proto | 63 ++ src/utils/vm_specs.cpp | 6 + 30 files changed, 2294 insertions(+), 6 deletions(-) create mode 100644 include/multipass/mdns_service.h create mode 100644 src/client/cli/cmd/migrate.cpp create mode 100644 src/client/cli/cmd/migrate.h create mode 100644 src/client/gui/lib/migrate/migrate_screen.dart create mode 100644 src/platform/mdns_service.cpp create mode 100644 src/platform/mdns_service_linux.cpp create mode 100644 src/platform/mdns_service_macos.cpp create mode 100644 src/platform/mdns_service_noop.cpp diff --git a/BUILD.linux.md b/BUILD.linux.md index 2907c25783..011469cc56 100644 --- a/BUILD.linux.md +++ b/BUILD.linux.md @@ -39,6 +39,18 @@ uses for things like the catalogue's per-service gateway CA fetch — without it running, deploying anything from the Catalogue fails with a connection- refused error trying to reach `127.0.0.1:7777/ca.crt`. +Migrating an instance/intent to another host (`elp migrate`, or the GUI's +"Migrate" action) also needs: +- `avahi-daemon.service` running (`sudo systemctl enable --now avahi-daemon`) + for elpd to advertise/discover other hosts on the network. This is + optional — migration itself still works with a manually-added host (GUI's + Migration Hosts page, or `elp migrate --to user@host` directly); avahi is + only what populates the automatic "discovered on network" list. +- Working SSH access (key-based, no password prompt) from this machine to + the target as the user given in `user@host` — migration shells out to + `ssh`/`rsync` under the hood, using your own `~/.ssh/config`/agent, and + `elp` already installed with its daemon running on the target. + ## Ubuntu / apt-based distributions ### Build dependencies diff --git a/include/multipass/mdns_service.h b/include/multipass/mdns_service.h new file mode 100644 index 0000000000..7473914daf --- /dev/null +++ b/include/multipass/mdns_service.h @@ -0,0 +1,84 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#pragma once + +#include + +#include +#include + +namespace multipass +{ + +// Advertised/discovered over the "_elp._tcp" mDNS service type. host_name/host_os/host_arch +// mirror DaemonInfoReply's own fields (see daemon_info's handler); a peer's label is its mDNS +// service instance name (not necessarily its host_name). +struct MdnsHostInfo +{ + std::string label; + std::string address; // resolved IP (or hostname, if resolution only gets that far) + std::string host_name; + std::string host_os; + std::string host_arch; + std::string backend; +}; + +struct MdnsAdvertisement +{ + std::string label; // mDNS service instance name for this daemon; default: host_name + std::string host_name; + std::string host_os; + std::string host_arch; + std::string backend; +}; + +// Advertises this elpd as "_elp._tcp" on the local network and browses for other instances +// of it, so the GUI's migration screen can offer a live list of candidate hosts alongside the +// manually-added "known hosts" list (list_network_hosts merges both — see Daemon::migrate's +// own doc comment on why migration itself doesn't talk to a peer's elpd directly: this service +// is purely informational/discovery, migration itself goes over SSH to the target's own CLI). +class MdnsService : public QObject +{ + Q_OBJECT +public: + ~MdnsService() override = default; + + // Begins advertising + browsing. Safe to call once; browsing/advertising continue until + // this object is destroyed. Never throws — failures (no mDNS daemon reachable, etc.) are + // logged and leave this a permanently-inert no-op rather than taking elpd down with it. + virtual void start() = 0; + +signals: + // Always emitted on this object's own thread; connect with an auto/queued connection to + // observe from Daemon's thread (see mdns_service_linux.cpp/mdns_service_macos.cpp — both + // run their platform library's event loop on a dedicated worker thread). + void host_discovered(multipass::MdnsHostInfo info); + void host_removed(std::string label); + +protected: + MdnsService(); +}; + +// Returns a platform-appropriate implementation (Avahi on Linux, Bonjour/dns_sd on macOS), or +// a permanently-inert no-op elsewhere (e.g. Windows) so callers never need to branch on +// platform themselves. +std::unique_ptr make_mdns_service(MdnsAdvertisement advertisement); + +} // namespace multipass + +Q_DECLARE_METATYPE(multipass::MdnsHostInfo) diff --git a/include/multipass/vm_specs.h b/include/multipass/vm_specs.h index fb98374560..2235a28bd7 100644 --- a/include/multipass/vm_specs.h +++ b/include/multipass/vm_specs.h @@ -49,6 +49,13 @@ struct VMSpecs // Marketplace service template id (e.g. caddy_ca_v1). Stored outside QEMU // metadata so backend metadata refreshes cannot wipe it. std::string service_id; + // The image alias/cloud-init this instance was originally launched with + // (as given to LaunchRequest, not the resolved local VMImage/YAML::Node + // this becomes once prepared) — otherwise discarded after launch, but + // needed to redefine the same instance elsewhere (migration). + std::string image; + std::string cloud_init_user_data; + std::string remote_name; friend inline bool operator==(const VMSpecs& a, const VMSpecs& b) = default; }; diff --git a/packaging/archlinux/PKGBUILD.in b/packaging/archlinux/PKGBUILD.in index 7a46dfc58d..c7ba5bbe4a 100644 --- a/packaging/archlinux/PKGBUILD.in +++ b/packaging/archlinux/PKGBUILD.in @@ -33,6 +33,10 @@ depends=( 'libkeybinder3' # GUI's hotkey_manager plugin links keybinder-3.0 at runtime 'libsecret' # flutter_secure_storage_linux plugin 'apparmor' # libapparmor.so.1, linked by elpd/libdart_ffi.so at runtime too + 'avahi' # elpd links libavahi-client/-common for mDNS host discovery (migration feature); + # also provides the avahi-daemon service migration's mDNS side needs running + 'openssh' # migration shells out to `ssh`/`rsync -e ssh` to reach a migration target + 'rsync' ) makedepends=( 'git' diff --git a/src/client/cli/client.cpp b/src/client/cli/client.cpp index 6080791b58..63d1e859b6 100644 --- a/src/client/cli/client.cpp +++ b/src/client/cli/client.cpp @@ -32,6 +32,7 @@ #include "cmd/launch.h" #include "cmd/list.h" #include "cmd/llm.h" +#include "cmd/migrate.h" #include "cmd/mount.h" #include "cmd/networks.h" #include "cmd/prefer.h" @@ -121,6 +122,7 @@ mp::Client::Client(ClientConfig& config) add_command(); add_command(); add_command(); + add_command(); add_command(); add_command(); add_command(); diff --git a/src/client/cli/cmd/CMakeLists.txt b/src/client/cli/cmd/CMakeLists.txt index a6cc714956..2ea723abf9 100644 --- a/src/client/cli/cmd/CMakeLists.txt +++ b/src/client/cli/cmd/CMakeLists.txt @@ -32,6 +32,7 @@ add_library(commands STATIC launch.cpp list.cpp llm.cpp + migrate.cpp mount.cpp networks.cpp prefer.cpp diff --git a/src/client/cli/cmd/migrate.cpp b/src/client/cli/cmd/migrate.cpp new file mode 100644 index 0000000000..bf4d96e466 --- /dev/null +++ b/src/client/cli/cmd/migrate.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include "migrate.h" + +#include "animated_spinner.h" +#include "common_cli.h" + +#include +#include + +namespace mp = multipass; +namespace cmd = multipass::cmd; + +namespace +{ +const QCommandLineOption to_option{"to", + "Target host, reachable over ssh, with elp already " + "installed and its daemon running (\"user@host\").", + "user@host"}; +const QCommandLineOption copy_option{ + "copy", "Keep the source instance(s)/session(s) instead of deleting them once migrated."}; +} // namespace + +mp::ReturnCodeVariant cmd::Migrate::run(ArgParser* parser) +{ + auto parsed = parse_args(parser); + if (parsed != ParseCode::Ok) + return parser->returnCodeFrom(parsed); + + MigrateRequest request; + request.set_name(instance_name); + request.set_target(target.toStdString()); + request.set_copy(copy); + request.set_verbosity_level(parser->verbosityLevel()); + + AnimatedSpinner spinner{cout}; + spinner.start(fmt::format("Migrating \"{}\" to {}", instance_name, target.toStdString())); + + auto on_success = [this, &spinner](MigrateReply& reply) -> ReturnCodeVariant { + spinner.stop(); + cout << reply.reply_message() << "\n"; + return ReturnCode::Ok; + }; + auto on_failure = [this, &spinner](grpc::Status& status, MigrateReply& reply) -> ReturnCodeVariant { + spinner.stop(); + return standard_failure_handler_for(name(), cerr, status, reply.reply_message()); + }; + auto streaming = [this, &spinner](const MigrateReply& reply, auto*) { + if (!reply.log_line().empty()) + { + spinner.stop(); + cout << reply.log_line() << "\n"; + spinner.start(fmt::format("Migrating \"{}\" to {}", instance_name, target.toStdString())); + } + }; + + return dispatch(&RpcMethod::migrate, request, on_success, on_failure, streaming); +} + +std::string cmd::Migrate::name() const +{ + return "migrate"; +} + +QString cmd::Migrate::short_help() const +{ + return QStringLiteral("Migrate an instance or intent to another elp host"); +} + +QString cmd::Migrate::description() const +{ + return QStringLiteral( + "Migrate a single instance or a whole intent to another host running elpd, " + "reachable over ssh. The instance(s) are stopped, redefined on the target with " + "the same image/cloud-init (or, for an LLM intent member, reloaded there), and " + "any mounts are synced across; the source is then deleted unless --copy is given.\n\n" + " elp migrate my-instance --to user@host\n" + " elp migrate my-intent --to user@host --copy"); +} + +mp::ParseCode cmd::Migrate::parse_args(ArgParser* parser) +{ + parser->addPositionalArgument("name", "Name of the instance or intent to migrate", ""); + parser->addOption(to_option); + parser->addOption(copy_option); + + const auto status = parser->commandParse(this); + if (status != ParseCode::Ok) + return status; + + const auto args = parser->positionalArguments(); + if (args.empty()) + { + cerr << "Please provide the name of an instance or intent to migrate.\n"; + return ParseCode::CommandLineError; + } + instance_name = args[0].toStdString(); + + if (!parser->isSet(to_option)) + { + cerr << "Please specify a target with --to user@host.\n"; + return ParseCode::CommandLineError; + } + target = parser->value(to_option); + copy = parser->isSet(copy_option); + + return ParseCode::Ok; +} diff --git a/src/client/cli/cmd/migrate.h b/src/client/cli/cmd/migrate.h new file mode 100644 index 0000000000..0d2eaf3778 --- /dev/null +++ b/src/client/cli/cmd/migrate.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#pragma once + +#include + +namespace multipass::cmd +{ +class Migrate final : public Command +{ +public: + using Command::Command; + ReturnCodeVariant run(ArgParser* parser) override; + + std::string name() const override; + QString short_help() const override; + QString description() const override; + +private: + ParseCode parse_args(ArgParser* parser); + + std::string instance_name; + QString target; + bool copy{false}; +}; +} // namespace multipass::cmd diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index 116ce8f39d..53018fb6d4 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -420,6 +420,29 @@ class GrpcClient { ); } + Future migrate(String name, String target, {bool copy = false}) { + return doRpc( + _client.migrate, + MigrateRequest(name: name, target: target, copy: copy), + ); + } + + Future> listNetworkHosts() { + return doRpc(_client.list_network_hosts, ListNetworkHostsRequest(), log: false) + .then((r) => r?.hosts.toList() ?? const []); + } + + Future addKnownHost(String label, String target) { + return doRpc( + _client.add_known_host, + AddKnownHostRequest(label: label, target: target), + ); + } + + Future removeKnownHost(String label) { + return doRpc(_client.remove_known_host, RemoveKnownHostRequest(label: label)); + } + Future authenticate(String passphrase) { return doRpc( _client.authenticate, diff --git a/src/client/gui/lib/intents/intents_screen.dart b/src/client/gui/lib/intents/intents_screen.dart index 6eb570c908..0cb6d02325 100644 --- a/src/client/gui/lib/intents/intents_screen.dart +++ b/src/client/gui/lib/intents/intents_screen.dart @@ -5,6 +5,7 @@ import '../confirmation_dialog.dart'; import '../l10n/app_localizations.dart'; import '../layout/compact_layout.dart'; import '../llm/llm_id.dart'; +import '../migrate/migrate_screen.dart'; import '../page_surface.dart'; import '../providers.dart'; import '../sidebar.dart'; @@ -120,6 +121,11 @@ class _IntentCard extends ConsumerWidget { icon: const Icon(Icons.add), onPressed: () => showAddMemberDialog(context, ref, intent.name), ), + IconButton( + tooltip: 'Migrate', + icon: const Icon(Icons.moving), + onPressed: () => showMigrateDialog(context, ref, name: intent.name), + ), IconButton( tooltip: 'Delete intent', icon: const Icon(Icons.delete_outline), diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index 10228c65c2..a1aeab882f 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -22,6 +22,7 @@ import 'daemon_unavailable.dart'; import 'downloads/download_status_list.dart'; import 'help.dart'; import 'intents/intents_screen.dart'; +import 'migrate/migrate_screen.dart'; import 'layout/compact_layout.dart'; import 'logger.dart'; import 'llm/catalogue/llm_catalogue_screen.dart'; @@ -274,6 +275,7 @@ class _AppState extends ConsumerState with WindowListener { CacheScreen.sidebarKey: const CacheScreen(), CloudInitScreen.sidebarKey: const CloudInitScreen(), IntentsScreen.sidebarKey: const IntentsScreen(), + HostsScreen.sidebarKey: const HostsScreen(), SettingsScreen.sidebarKey: const SettingsScreen(), HelpScreen.sidebarKey: const HelpScreen(), for (final id in vms) id.sidebarKey: VmDetailsScreen(id), diff --git a/src/client/gui/lib/migrate/migrate_screen.dart b/src/client/gui/lib/migrate/migrate_screen.dart new file mode 100644 index 0000000000..1f4ef7a981 --- /dev/null +++ b/src/client/gui/lib/migrate/migrate_screen.dart @@ -0,0 +1,388 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../confirmation_dialog.dart'; +import '../layout/compact_layout.dart'; +import '../notifications.dart'; +import '../page_surface.dart'; +import '../providers.dart'; +import '../widgets/launchpad_button.dart'; + +/// Manages the "known hosts" list migration targets can be picked from (see +/// Daemon::list_network_hosts) — hosts discovered on the network via mDNS need no entry +/// here at all, this is only for the manually-added fallback (a different subnet/VLAN, +/// or mDNS just not being reachable). +class HostsScreen extends ConsumerWidget { + static const sidebarKey = 'hosts'; + + const HostsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hostsAsync = ref.watch(networkHostsStreamProvider); + final onSurface = Theme.of(context).colorScheme.onSurface; + + return Scaffold( + body: PageSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Migration Hosts', + style: TextStyle(fontSize: 37, fontWeight: FontWeight.w300), + ), + ), + IconButton( + tooltip: 'Refresh', + onPressed: () => ref.invalidate(networkHostsStreamProvider), + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 8), + LaunchPadButton.primary( + onPressed: () => showAddHostDialog(context, ref), + child: const Text('Add host'), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Targets for "Migrate" (see an instance or intent\'s own menu). Hosts on the ' + 'same network are found automatically; add one by hand if it\'s on a ' + 'different network or isn\'t found.', + style: TextStyle(fontSize: 14, color: onSurface.withValues(alpha: 0.7)), + ), + const SizedBox(height: 24), + Expanded( + child: hostsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('$error')), + data: (hosts) => hosts.isEmpty + ? Center( + child: Text( + 'No hosts yet. Add one to migrate instances there.', + style: TextStyle(color: onSurface.withValues(alpha: 0.6)), + ), + ) + : ListView.separated( + itemCount: hosts.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) => _HostCard(host: hosts[index]), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _HostCard extends ConsumerWidget { + const _HostCard({required this.host}); + + final NetworkHost host; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final onSurface = Theme.of(context).colorScheme.onSurface; + final subtitle = [ + if (host.hostOs.isNotEmpty) host.hostOs, + if (host.hostArch.isNotEmpty) host.hostArch, + if (host.backend.isNotEmpty) host.backend, + if (host.address.isNotEmpty) host.address, + ].join(' · '); + + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: onSurface.withValues(alpha: 0.15)), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + host.discovered ? Icons.wifi_tethering : Icons.dns_outlined, + color: onSurface.withValues(alpha: 0.7), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(host.label, style: const TextStyle(fontWeight: FontWeight.w500)), + if (subtitle.isNotEmpty) + Text(subtitle, style: TextStyle(color: onSurface.withValues(alpha: 0.6))), + if (host.target.isNotEmpty) + Text(host.target, style: TextStyle(color: onSurface.withValues(alpha: 0.6))), + ], + ), + ), + Text( + host.discovered ? 'On network' : 'Known host', + style: TextStyle(fontSize: 12, color: onSurface.withValues(alpha: 0.5)), + ), + if (!host.discovered) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline), + onPressed: () => showRemoveHostDialog(context, ref, host.label), + ), + ], + ], + ), + ), + ); + } +} + +Future showAddHostDialog(BuildContext context, WidgetRef ref) async { + final labelController = TextEditingController(); + final targetController = TextEditingController(); + String? error; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + shape: const Border(), + title: const Text('Add migration host'), + content: SizedBox( + width: CompactLayout.dialogWidth(dialogContext, 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: labelController, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Label', + hintText: 'e.g. office-desktop', + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: targetController, + decoration: const InputDecoration( + labelText: 'Target', + hintText: 'user@host', + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + error!, + style: TextStyle(color: Theme.of(dialogContext).colorScheme.error), + ), + ), + ], + ), + ), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + LaunchPadButton.primary( + onPressed: () async { + final label = labelController.text.trim(); + final target = targetController.text.trim(); + if (label.isEmpty) { + setDialogState(() => error = 'Please provide a label.'); + return; + } + if (!target.contains('@')) { + setDialogState(() => error = 'Target must be in "user@host" form.'); + return; + } + try { + await ref.read(grpcClientProvider).addKnownHost(label, target); + ref.invalidate(networkHostsStreamProvider); + if (dialogContext.mounted) Navigator.pop(dialogContext); + } catch (e) { + if (dialogContext.mounted) setDialogState(() => error = '$e'); + } + }, + child: const Text('Add'), + ), + ], + ), + ), + ); + + labelController.dispose(); + targetController.dispose(); +} + +Future showRemoveHostDialog( + BuildContext context, + WidgetRef ref, + String label, +) async { + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ConfirmationDialog( + title: 'Remove host', + body: Text('Remove "$label" from the migration host list?'), + actionText: 'Remove', + onAction: () => Navigator.pop(dialogContext, true), + inactionText: 'Cancel', + onInaction: () => Navigator.pop(dialogContext, false), + ), + ); + if (confirmed != true) return; + + try { + await ref.read(grpcClientProvider).removeKnownHost(label); + ref.invalidate(networkHostsStreamProvider); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } +} + +/// Dialog used from an instance's or intent's own menu to migrate it elsewhere: pick a +/// discovered/known host (or type a brand new "user@host"), optionally keep the source. +Future showMigrateDialog( + BuildContext context, + WidgetRef ref, { + required String name, +}) async { + String? selectedLabel; + final customTargetController = TextEditingController(); + final usernameController = TextEditingController(); + var copy = false; + String? error; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => Consumer( + builder: (dialogContext, ref, _) { + final hosts = ref.watch(networkHostsStreamProvider).asData?.value ?? const []; + final selected = selectedLabel == null + ? null + : hosts.firstWhereOrNull((h) => h.label == selectedLabel); + final needsUsername = selected != null && selected.target.isEmpty; + + return AlertDialog( + shape: const Border(), + title: Text('Migrate "$name"'), + content: SizedBox( + width: CompactLayout.dialogWidth(dialogContext, 440), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: selectedLabel, + decoration: const InputDecoration(labelText: 'Target host'), + items: [ + const DropdownMenuItem(value: null, child: Text('Type a new host...')), + for (final host in hosts) + DropdownMenuItem(value: host.label, child: Text(host.label)), + ], + onChanged: (value) => setDialogState(() { + selectedLabel = value; + error = null; + }), + ), + if (selected == null) ...[ + const SizedBox(height: 12), + TextFormField( + controller: customTargetController, + decoration: const InputDecoration( + labelText: 'Target', + hintText: 'user@host', + ), + ), + ] else if (needsUsername) ...[ + const SizedBox(height: 12), + TextFormField( + controller: usernameController, + decoration: InputDecoration( + labelText: 'SSH username on ${selected.address.isNotEmpty ? selected.address : selected.hostName}', + ), + ), + ], + const SizedBox(height: 12), + CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + title: const Text('Keep the source instead of deleting it'), + value: copy, + onChanged: (value) => setDialogState(() => copy = value ?? false), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + error!, + style: TextStyle(color: Theme.of(dialogContext).colorScheme.error), + ), + ), + ], + ), + ), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + LaunchPadButton.primary( + onPressed: () { + final String target; + if (selected == null) { + target = customTargetController.text.trim(); + } else if (!needsUsername) { + target = selected.target; + } else { + final username = usernameController.text.trim(); + final address = + selected.address.isNotEmpty ? selected.address : selected.hostName; + target = username.isEmpty ? '' : '$username@$address'; + } + if (!target.contains('@')) { + setDialogState(() => error = 'Please provide a valid "user@host" target.'); + return; + } + + Navigator.pop(dialogContext); + final op = ref.read(grpcClientProvider).migrate(name, target, copy: copy); + ref.read(notificationsProvider.notifier).addOperation( + op, + loading: 'Migrating "$name" to $target...', + onSuccess: (reply) { + final message = reply?.replyMessage; + return message?.isNotEmpty == true + ? message! + : 'Migrated "$name" to $target'; + }, + onError: (e) => '$e', + ); + op.whenComplete(() { + ref.invalidate(intentsStreamProvider); + }); + }, + child: const Text('Migrate'), + ), + ], + ); + }, + ), + ), + ); + + customTargetController.dispose(); + usernameController.dispose(); +} diff --git a/src/client/gui/lib/providers.dart b/src/client/gui/lib/providers.dart index 305400f896..226e24aa18 100644 --- a/src/client/gui/lib/providers.dart +++ b/src/client/gui/lib/providers.dart @@ -326,6 +326,28 @@ final intentNamesProvider = Provider>((ref) { return {for (final intent in intents) intent.name}.toBuiltSet(); }); +/// Candidate migration targets: hosts discovered on the network via mDNS, merged with the +/// manually-added "known hosts" list (see `Daemon::list_network_hosts` for how these merge — +/// a discovered host whose label matches a known one carries that known host's saved target). +final networkHostsStreamProvider = StreamProvider>((ref) async* { + if (!ref.watch(daemonAvailableProvider)) { + yield const []; + return; + } + final grpcClient = ref.watch(grpcClientProvider); + while (true) { + final timer = Future.delayed(2900.milliseconds); + try { + yield await grpcClient.listNetworkHosts(); + } catch (error, stackTrace) { + logger.w('Error on polling list_network_hosts', error: error, stackTrace: stackTrace); + yield const []; + } + await timer; + await Future.delayed(100.milliseconds); + } +}); + class AllVmInfosNotifier extends Notifier> { @override List build() { diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index 2f63d93685..bd059d3853 100644 --- a/src/client/gui/lib/sidebar.dart +++ b/src/client/gui/lib/sidebar.dart @@ -21,6 +21,7 @@ import 'downloads/download_manager.dart'; import 'glass_panel.dart'; import 'help.dart'; import 'intents/intents_screen.dart'; +import 'migrate/migrate_screen.dart'; import 'l10n/app_localizations.dart'; import 'layout/compact_layout.dart'; import 'llm/catalogue/llm_catalogue_screen.dart'; @@ -310,6 +311,15 @@ class SideBar extends ConsumerWidget { }, ); + final hosts = SidebarEntry( + icon: FontAwesomeIcons.networkWired, + selected: isSelected(HostsScreen.sidebarKey), + label: 'Migration Hosts', + onPressed: () { + ref.read(sidebarKeyNotifier).set(HostsScreen.sidebarKey); + }, + ); + final instances = SidebarEntry( icon: FontAwesomeIcons.server, selected: isSelected(VmTableScreen.sidebarKey) || @@ -588,6 +598,7 @@ class SideBar extends ConsumerWidget { SidebarSectionHeader(l10n.sidebarSectionManage), llmSetup, cache, + hosts, help, const Spacer(), Divider(color: fg.withAlpha(40), height: 1), diff --git a/src/client/gui/lib/vm_details/vm_details_general.dart b/src/client/gui/lib/vm_details/vm_details_general.dart index 823a2f5d31..4260c441eb 100644 --- a/src/client/gui/lib/vm_details/vm_details_general.dart +++ b/src/client/gui/lib/vm_details/vm_details_general.dart @@ -7,6 +7,7 @@ import '../copyable_text.dart'; import '../daemon_source.dart'; import '../extensions.dart'; import '../l10n/app_localizations.dart'; +import '../migrate/migrate_screen.dart'; import '../multipass_chip.dart'; import '../providers.dart'; import 'cpu_sparkline.dart'; @@ -119,6 +120,12 @@ class VmDetailsHeader extends ConsumerWidget { memory, disk, VmActionButtons(id), + if (id.source == DaemonSource.elp) + IconButton( + tooltip: 'Migrate to another host', + icon: const Icon(Icons.moving), + onPressed: () => showMigrateDialog(context, ref, name: id.name), + ), ], ), ); diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index f2b86ad7ae..20c202c6e7 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -48,6 +48,8 @@ #include #include #include +#include +#include #include #include #include @@ -171,6 +173,7 @@ bool is_instance_home(const QString& target, const std::string& username) constexpr auto category = "daemon"; constexpr auto instance_db_name = "multipassd-vm-instances.json"; constexpr auto intent_db_name = "multipassd-intents.json"; +constexpr auto known_hosts_db_name = "multipassd-known-hosts.json"; constexpr auto sshfs_error_template = "Error enabling mount support in '{}'" "\n\nPlease install the 'multipass-sshfs' snap manually inside the instance."; @@ -408,6 +411,34 @@ std::unordered_map load_intents_db(const mp::Path& return reconstructed_records; } +// Manually-added migration targets ("label" -> "user@host"), the always-available fallback +// to mDNS discovery for hosts on a different subnet/VLAN where multicast doesn't reach. +std::unordered_map load_known_hosts_db(const mp::Path& data_path) +{ + QDir data_dir{data_path}; + QFile db_file{data_dir.filePath(known_hosts_db_name)}; + if (!db_file.open(QIODevice::ReadOnly)) + return {}; + + boost::json::value records; + try + { + records = boost::json::parse(std::string_view(db_file.readAll())); + } + catch (const std::runtime_error&) + { + return {}; + } + + std::unordered_map reconstructed_records; + for (const auto& [key, record] : records.as_object()) + { + if (record.is_string()) + reconstructed_records.emplace(key, std::string(record.as_string())); + } + return reconstructed_records; +} + std::string generate_next_clone_name(int clone_count, const std::string& source_name) { return fmt::format("{}-clone{}", source_name, clone_count + 1); @@ -693,6 +724,16 @@ auto connect_rpc(mp::DaemonRpc& rpc, mp::Daemon& daemon, mp::LlmDispatcher* llm_ QObject::connect(&rpc, &mp::DaemonRpc::on_cache_info, &daemon, &mp::Daemon::cache_info); QObject::connect(&rpc, &mp::DaemonRpc::on_cache_delete, &daemon, &mp::Daemon::cache_delete); QObject::connect(&rpc, &mp::DaemonRpc::on_wait_ready, &daemon, &mp::Daemon::wait_ready); + QObject::connect(&rpc, &mp::DaemonRpc::on_migrate, &daemon, &mp::Daemon::migrate); + QObject::connect(&rpc, + &mp::DaemonRpc::on_list_network_hosts, + &daemon, + &mp::Daemon::list_network_hosts); + QObject::connect(&rpc, &mp::DaemonRpc::on_add_known_host, &daemon, &mp::Daemon::add_known_host); + QObject::connect(&rpc, + &mp::DaemonRpc::on_remove_known_host, + &daemon, + &mp::Daemon::remove_known_host); QObject::connect(&rpc, &mp::DaemonRpc::on_zones, &daemon, &mp::Daemon::zones); QObject::connect(&rpc, &mp::DaemonRpc::on_zones_state, &daemon, &mp::Daemon::zones_state); if (llm_dispatcher) @@ -1479,6 +1520,27 @@ mp::Daemon::Daemon(std::unique_ptr the_config) intents = load_intents_db(mp::utils::backend_directory_path( config->data_directory, config->factory->get_backend_directory_name())); + known_hosts = load_known_hosts_db(mp::utils::backend_directory_path( + config->data_directory, config->factory->get_backend_directory_name())); + + { + MdnsAdvertisement advertisement; + advertisement.host_name = QHostInfo::localHostName().toStdString(); + advertisement.host_os = QSysInfo::prettyProductName().toStdString(); + advertisement.host_arch = QSysInfo::currentCpuArchitecture().toStdString(); + advertisement.backend = config->factory->get_backend_directory_name().toStdString(); + mdns_service = mp::make_mdns_service(advertisement); + + QObject::connect(mdns_service.get(), + &MdnsService::host_discovered, + this, + [this](MdnsHostInfo info) { discovered_hosts[info.label] = std::move(info); }); + QObject::connect(mdns_service.get(), + &MdnsService::host_removed, + this, + [this](std::string label) { discovered_hosts.erase(label); }); + mdns_service->start(); + } std::vector invalid_specs; @@ -3767,6 +3829,620 @@ catch (const std::exception& e) context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); } +namespace +{ +// Migration drives the target purely through its own local `elp` CLI over SSH (rather than +// a new daemon-to-daemon RPC): the target's elpd already auto-trusts a client cert connecting +// over its local unix socket, so this avoids needing the target's local.passphrase set and a +// prior `elp authenticate` run just to migrate something there. See the migration plan section +// for the fuller rationale. + +std::string shell_quote(const std::string& s) +{ + std::string out = "'"; + for (char c : s) + out += (c == '\'') ? "'\\''" : std::string(1, c); + out += "'"; + return out; +} + +std::string join_shell_command(const std::vector& args) +{ + std::string cmd; + for (const auto& a : args) + { + if (!cmd.empty()) + cmd += ' '; + cmd += shell_quote(a); + } + return cmd; +} + +struct RemoteResult +{ + bool ok{false}; + QString output; + QString error; +}; + +// Runs `remote_command` on `target` ("user@host") over ssh, optionally piping `stdin_data` +// into it (forwarded transparently through the ssh tunnel to the remote process' own stdin — +// e.g. this is how `elp launch --cloud-init -` receives cloud-init content without needing a +// remote temp file). `remote_command` is executed by the target's login shell, so anything +// assembled from user-controlled fragments must already be shell-escaped by the caller +// (see join_shell_command/shell_quote). +RemoteResult run_ssh_raw(const std::string& target, + const std::string& remote_command, + const std::string& stdin_data, + int timeout_ms) +{ + const QStringList args{"-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-T", + QString::fromStdString(target), + QString::fromStdString(remote_command)}; + auto process = mp::platform::make_process(mp::simple_process_spec("ssh", args)); + process->start(); + if (!process->wait_for_started(timeout_ms)) + return {false, {}, "ssh failed to start (is it on PATH?)"}; + if (!stdin_data.empty()) + process->write(QByteArray::fromStdString(stdin_data)); + process->close_write_channel(); + if (!process->wait_for_finished(timeout_ms)) + return {false, process->read_all_standard_output(), "ssh timed out"}; + + const auto state = process->process_state(); + const auto out = process->read_all_standard_output(); + const auto err = process->read_all_standard_error(); + if (!state.completed_successfully()) + return {false, out, err.isEmpty() ? state.failure_message() : QString(err)}; + return {true, out, {}}; +} + +RemoteResult run_ssh(const std::string& target, + const std::vector& remote_args, + const std::string& stdin_data = {}, + int timeout_ms = 120000) +{ + return run_ssh_raw(target, join_shell_command(remote_args), stdin_data, timeout_ms); +} + +// rsync's own remote-path argument (user@host:/path) is parsed by rsync itself, not passed +// through a shell the way an ssh command string is — dest_path is not shell_quote'd here. +RemoteResult run_rsync(const std::string& source_path, + const std::string& target, + const std::string& dest_path, + int timeout_ms) +{ + const QStringList args{ + "-az", + "-e", + "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", + "--mkpath", + QString::fromStdString(source_path) + "/", + QString::fromStdString(target) + ":" + QString::fromStdString(dest_path)}; + auto process = mp::platform::make_process(mp::simple_process_spec("rsync", args)); + const auto state = process->execute(timeout_ms); + const auto out = process->read_all_standard_output(); + const auto err = process->read_all_standard_error(); + if (!state.completed_successfully()) + return {false, out, err.isEmpty() ? state.failure_message() : QString(err)}; + return {true, out, {}}; +} + +struct MigrateVmMember +{ + std::string role; // empty for a standalone (non-intent) instance + std::string instance_name; + std::string image; + std::string remote_name; + std::string cloud_init_user_data; + int num_cores{1}; + std::string mem_size; + std::string disk_space; + std::unordered_map mounts; // keyed by target_path +}; + +struct MigrateLlmMember +{ + std::string role; + std::string instance_name; // the loaded session's instance_id, for unloading on success + std::string model_id; + std::string runtime; // "llamacpp" | "mlx", same strings LoadedSession::backend already uses + int ctx_size{4096}; + int max_tokens{0}; +}; + +struct MigrationOutcome +{ + bool success{false}; + std::string message; + std::vector log_lines; +}; + +std::string image_arg_for(const MigrateVmMember& member) +{ + return member.remote_name.empty() ? member.image + : fmt::format("{}:{}", member.remote_name, member.image); +} + +// Redefines one standalone VM member on the target via `elp launch`, piping cloud-init (if +// any) through the ssh tunnel's stdin, then `elp mount`s each of its mounts (having already +// rsynced their host-side source directories there — see perform_migration). +bool migrate_standalone_vm(const MigrateVmMember& member, + const std::string& target, + MigrationOutcome& outcome) +{ + std::vector args{"elp", + "launch", + image_arg_for(member), + "--name", + member.instance_name, + "--cpus", + std::to_string(member.num_cores), + "--memory", + member.mem_size, + "--disk", + member.disk_space}; + if (!member.cloud_init_user_data.empty()) + { + args.push_back("--cloud-init"); + args.push_back("-"); + } + + outcome.log_lines.push_back( + fmt::format("Relaunching \"{}\" on {}...", member.instance_name, target)); + auto result = run_ssh(target, args, member.cloud_init_user_data, 600000); + if (!result.ok) + { + outcome.message = fmt::format("Failed to launch \"{}\" on {}: {}", + member.instance_name, + target, + result.error.toStdString()); + return false; + } + + for (const auto& [target_path, mount] : member.mounts) + { + outcome.log_lines.push_back( + fmt::format("Syncing mount {} -> {}...", mount.get_source_path(), target)); + auto rsync_result = run_rsync(mount.get_source_path(), target, mount.get_source_path(), 1800000); + if (!rsync_result.ok) + { + outcome.message = fmt::format("Failed to sync mount \"{}\": {}", + mount.get_source_path(), + rsync_result.error.toStdString()); + return false; + } + + auto mount_result = run_ssh( + target, + {"elp", "mount", mount.get_source_path(), fmt::format("{}:{}", member.instance_name, target_path)}, + {}, + 60000); + if (!mount_result.ok) + { + outcome.message = fmt::format( + "Launched \"{}\" on {}, but mounting \"{}\" there failed: {}", + member.instance_name, + target, + target_path, + mount_result.error.toStdString()); + return false; + } + } + + return true; +} + +// Redefines a whole intent's members in one `elp intent create` call on the target: VM +// members with cloud-init need it staged as a remote temp file first (the --instance spec's +// cloud-init field is a path the target's own elp CLI reads locally, unlike a plain `elp +// launch`, which can take it over stdin instead). +bool migrate_intent(const std::string& intent_name, + const std::vector& vm_members, + const std::vector& llm_members, + const std::string& target, + MigrationOutcome& outcome) +{ + std::vector remote_tmp_files; + std::vector args{"elp", "intent", "create", intent_name}; + + for (const auto& member : vm_members) + { + std::string cloud_init_path; + if (!member.cloud_init_user_data.empty()) + { + cloud_init_path = + fmt::format("/tmp/elp-migrate-{}-{}.yaml", + intent_name, + member.role.empty() ? member.instance_name : member.role); + outcome.log_lines.push_back( + fmt::format("Staging cloud-init for \"{}\" on {}...", member.role, target)); + auto put = run_ssh_raw(target, + fmt::format("cat > {}", shell_quote(cloud_init_path)), + member.cloud_init_user_data, + 60000); + if (!put.ok) + { + outcome.message = fmt::format("Failed to stage cloud-init for \"{}\" on {}: {}", + member.role, + target, + put.error.toStdString()); + return false; + } + remote_tmp_files.push_back(cloud_init_path); + } + + args.push_back("--instance"); + args.push_back(fmt::format("{}:{}:{}:{}:{}:{}", + member.role, + image_arg_for(member), + cloud_init_path, + member.num_cores, + member.mem_size, + member.disk_space)); + } + + for (const auto& member : llm_members) + { + args.push_back("--model"); + args.push_back(fmt::format("{}:{}", member.role, member.model_id)); + } + + outcome.log_lines.push_back(fmt::format("Creating intent \"{}\" on {}...", intent_name, target)); + auto result = run_ssh(target, args, {}, 900000); + + if (!remote_tmp_files.empty()) + { + std::vector rm_args{"rm", "-f"}; + rm_args.insert(rm_args.end(), remote_tmp_files.begin(), remote_tmp_files.end()); + run_ssh(target, rm_args, {}, 30000); // best-effort cleanup, failure not fatal + } + + if (!result.ok) + { + outcome.message = + fmt::format("Failed to create intent \"{}\" on {}: {}", intent_name, target, result.error.toStdString()); + return false; + } + + for (const auto& member : vm_members) + { + for (const auto& [target_path, mount] : member.mounts) + { + const auto remote_instance = fmt::format("{}-{}", intent_name, member.role); + outcome.log_lines.push_back( + fmt::format("Syncing mount {} -> {}...", mount.get_source_path(), target)); + auto rsync_result = + run_rsync(mount.get_source_path(), target, mount.get_source_path(), 1800000); + if (!rsync_result.ok) + { + outcome.message = fmt::format("Failed to sync mount \"{}\": {}", + mount.get_source_path(), + rsync_result.error.toStdString()); + return false; + } + + auto mount_result = run_ssh( + target, + {"elp", "mount", mount.get_source_path(), fmt::format("{}:{}", remote_instance, target_path)}, + {}, + 60000); + if (!mount_result.ok) + { + outcome.message = fmt::format("Intent created on {}, but mounting \"{}\" for \"{}\" failed: {}", + target, + target_path, + member.role, + mount_result.error.toStdString()); + return false; + } + } + } + + return true; +} + +MigrationOutcome perform_migration(const std::string& name, + bool is_intent, + const std::string& target, + std::vector vm_members, + std::vector llm_members) +{ + MigrationOutcome outcome; + + if (is_intent) + { + // One combined `elp intent create ... --instance ... --model ...` call, exactly + // mirroring how intent_create/build_intent_member_load_requests already accept both + // kinds of member together — this is what actually (re-)registers each member (VM or + // LLM) into the target's own intents map; `elp llm load --intent` alone only tags the + // session, it doesn't touch the registry (see Daemon::migrate's own doc comment). + if (!migrate_intent(name, vm_members, llm_members, target, outcome)) + return outcome; + } + else + { + assert(vm_members.size() == 1 && llm_members.empty()); + if (!migrate_standalone_vm(vm_members.front(), target, outcome)) + return outcome; + } + + outcome.success = true; + outcome.message = fmt::format("\"{}\" migrated to {}.", name, target); + return outcome; +} +} // namespace + +void mp::Daemon::migrate(const MigrateRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + const auto& name = request->name(); + const auto& target = request->target(); + const auto copy = request->copy(); + + if (name.empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Please provide an instance or intent name", ""}); + if (target.find('@') == std::string::npos) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Target must be in \"user@host\" form", ""}); + + std::vector vm_members; + std::vector llm_members; + const auto is_intent = intents.count(name) > 0; + + if (is_intent) + { + for (const auto& member : intents.at(name).members) + { + if (member.kind == "llm") + { + auto info = llm_dispatcher ? llm_dispatcher->instance_info(member.instance_name) + : std::nullopt; + if (!info) + return context->set_value( + {grpc::StatusCode::FAILED_PRECONDITION, + fmt::format("LLM member \"{}\" is not currently loaded; cannot migrate", + member.role), + ""}); + llm_members.push_back({member.role, + member.instance_name, + info->model_id(), + info->backend(), + info->ctx_size(), + info->max_tokens()}); + } + else + { + auto spec_it = vm_instance_specs.find(member.instance_name); + if (spec_it == vm_instance_specs.end()) + return context->set_value( + {grpc::StatusCode::FAILED_PRECONDITION, + fmt::format("VM member \"{}\" not found; cannot migrate", member.role), + ""}); + const auto& specs = spec_it->second; + vm_members.push_back({member.role, + member.instance_name, + specs.image, + specs.remote_name, + specs.cloud_init_user_data, + specs.num_cores, + specs.mem_size.human_readable(), + specs.disk_space.human_readable(), + specs.mounts}); + } + } + if (vm_members.empty() && llm_members.empty()) + return context->set_value({grpc::StatusCode::FAILED_PRECONDITION, + fmt::format("Intent \"{}\" has no members to migrate", name), + ""}); + } + else + { + auto spec_it = vm_instance_specs.find(name); + if (spec_it == vm_instance_specs.end()) + return context->set_value( + {grpc::StatusCode::NOT_FOUND, + fmt::format("\"{}\" is not an existing intent or instance name", name), + ""}); + const auto& specs = spec_it->second; + vm_members.push_back({"", + name, + specs.image, + specs.remote_name, + specs.cloud_init_user_data, + specs.num_cores, + specs.mem_size.human_readable(), + specs.disk_space.human_readable(), + specs.mounts}); + } + + // Stop every VM member first (v1 constraint, matches existing snapshot semantics — no live + // migration): synchronous, on the daemon's own thread, same call `elp stop --force` uses. + // LLM members aren't stopped; there's nothing to make consistent before copying a read-only + // model file, and "redefine" for them just means loading the model fresh on the target. + for (const auto& member : vm_members) + { + auto vm_it = operative_instances.find(member.instance_name); + if (vm_it != operative_instances.end()) + switch_off_vm(*vm_it->second); + } + + // The actual ssh/rsync work happens on a background thread (QtConcurrent, the same async + // pattern create_vm's own preparation step already uses) so it can't block the daemon for + // however long the network transfer takes; only the commit step below is marshaled back to + // the daemon's own thread via QFutureWatcher::finished (as usual), since that's what touches + // `intents`/`vm_instance_specs`. + auto future = QtConcurrent::run( + [name, is_intent, target, vm_members, llm_members]() mutable { + return perform_migration(name, is_intent, target, std::move(vm_members), std::move(llm_members)); + }); + + auto* watcher = new QFutureWatcher(); + QObject::connect( + watcher, + &QFutureWatcher::finished, + [this, watcher, server, context, name, is_intent, copy, vm_members, llm_members] { + auto outcome = watcher->future().result(); + watcher->deleteLater(); + + for (const auto& line : outcome.log_lines) + { + MigrateReply reply; + reply.set_log_line(line); + server->Write(reply); + } + + if (!outcome.success) + return context->set_value( + {grpc::StatusCode::INTERNAL, outcome.message, ""}); + + if (!copy) + { + auto instances_dirty = false; + for (const auto& member : vm_members) + { + auto vm_it = operative_instances.find(member.instance_name); + if (vm_it == operative_instances.end()) + continue; + DeleteReply throwaway; + instances_dirty |= delete_vm(vm_it, /*purge=*/true, throwaway); + } + if (instances_dirty) + persist_instances(); + + if (llm_dispatcher && !llm_members.empty()) + { + std::vector ids; + for (const auto& member : llm_members) + ids.push_back(member.instance_name); + llm_dispatcher->unload_instances_blocking(ids); + } + + if (is_intent) + { + intents.erase(name); + persist_intents(); + } + } + + MigrateReply reply; + reply.set_reply_message(outcome.message); + server->Write(reply); + context->set_value(grpc::Status::OK); + }); + watcher->setFuture(future); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + +void mp::Daemon::list_network_hosts( + const ListNetworkHostsRequest*, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + ListNetworkHostsReply response; + + for (const auto& [label, info] : discovered_hosts) + { + auto* host = response.add_hosts(); + host->set_label(label); + host->set_host_name(info.host_name); + host->set_host_os(info.host_os); + host->set_host_arch(info.host_arch); + host->set_backend(info.backend); + host->set_address(info.address); + host->set_discovered(true); + + // If this discovered peer's label matches a known host, surface the saved "user@host" + // for it too, so the GUI can pre-fill a target instead of asking for a username again. + if (auto it = known_hosts.find(label); it != known_hosts.end()) + host->set_target(it->second); + } + + for (const auto& [label, target] : known_hosts) + { + if (discovered_hosts.count(label)) + continue; // already listed above, with the richer discovered info + + auto* host = response.add_hosts(); + host->set_label(label); + host->set_target(target); + host->set_discovered(false); + } + + server->Write(response); + context->set_value(grpc::Status::OK); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + +void mp::Daemon::add_known_host( + const AddKnownHostRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + const auto& label = request->label(); + const auto& target = request->target(); + + if (label.empty()) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Please provide a label for this host", ""}); + if (target.find('@') == std::string::npos) + return context->set_value( + {grpc::StatusCode::INVALID_ARGUMENT, "Target must be in \"user@host\" form", ""}); + + known_hosts[label] = target; + persist_known_hosts(); + + AddKnownHostReply reply; + reply.set_reply_message(fmt::format("Added \"{}\" ({})", label, target)); + server->Write(reply); + context->set_value(grpc::Status::OK); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + +void mp::Daemon::remove_known_host( + const RemoveKnownHostRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context) +try +{ + auto it = known_hosts.find(request->label()); + if (it == known_hosts.end()) + return context->set_value( + {grpc::StatusCode::NOT_FOUND, + fmt::format("\"{}\" is not a known host", request->label()), + ""}); + + known_hosts.erase(it); + persist_known_hosts(); + + RemoveKnownHostReply reply; + reply.set_reply_message(fmt::format("Removed \"{}\"", request->label())); + server->Write(reply); + context->set_value(grpc::Status::OK); +} +catch (const std::exception& e) +{ + context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); +} + void mp::Daemon::daemon_info( const DaemonInfoRequest*, grpc::ServerReaderWriterInterface* server, @@ -3814,6 +4490,7 @@ try response.set_host_os(QSysInfo::prettyProductName().toStdString()); response.set_host_arch(QSysInfo::currentCpuArchitecture().toStdString()); response.set_host_uptime_seconds(host_uptime_seconds()); + response.set_backend(config->factory->get_backend_directory_name().toStdString()); for (const auto& claim : resource_pool->claims()) { @@ -4073,6 +4750,18 @@ void mp::Daemon::persist_intents() pretty_print(intent_records_json)); } +void mp::Daemon::persist_known_hosts() +{ + boost::json::object records; + for (const auto& [label, target] : known_hosts) + records[label] = target; + + QDir data_dir{mp::utils::backend_directory_path(config->data_directory, + config->factory->get_backend_directory_name())}; + MP_FILEOPS.write_transactionally(data_dir.filePath(known_hosts_db_name), + pretty_print(boost::json::value(records))); +} + void mp::Daemon::release_resources(const std::string& instance) { release_vm_claim(instance); @@ -4159,7 +4848,10 @@ void mp::Daemon::create_vm(const CreateRequest* request, prepare_future_watcher, service_id = std::string{request->service_id()}, intent = std::string{request->intent()}, - intent_role = std::string{request->intent_role()}] { + intent_role = std::string{request->intent_role()}, + image = std::string{request->image()}, + cloud_init_user_data = std::string{request->cloud_init_user_data()}, + remote_name = std::string{request->remote_name()}] { // Per-RPC ClientLogger lifecycle is managed by DaemonRpcContextImpl. try @@ -4189,6 +4881,9 @@ void mp::Daemon::create_vm(const CreateRequest* request, 0, vm_desc.zone, service_id, + image, + cloud_init_user_data, + remote_name, }; operative_instances[name] = config->factory->create_virtual_machine(vm_desc, diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index e6f85dac1c..bf319dd317 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,7 @@ class Daemon : public QObject, public multipass::VMStatusMonitor void persist_instances(); void persist_intents(); + void persist_known_hosts(); protected: using InstanceTable = std::unordered_map; @@ -221,6 +223,25 @@ public slots: grpc::ServerReaderWriterInterface* server, DaemonRpcContext* context); + virtual void migrate(const MigrateRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + + virtual void list_network_hosts( + const ListNetworkHostsRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + + virtual void add_known_host( + const AddKnownHostRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + + virtual void remove_known_host( + const RemoveKnownHostRequest* request, + grpc::ServerReaderWriterInterface* server, + DaemonRpcContext* context); + private: void release_resources(const std::string& instance); void create_vm(const CreateRequest* request, @@ -333,11 +354,14 @@ public slots: std::unique_ptr resource_pool; std::unique_ptr llm_dispatcher; QThread llm_thread; + std::unique_ptr mdns_service; protected: std::unordered_map vm_instance_specs; std::unordered_map intents; InstanceTable operative_instances; + std::unordered_map known_hosts; // label -> "user@host" + std::unordered_map discovered_hosts; // label -> info bool is_bridged(const std::string& instance_name) const; void add_bridged_interface(const std::string& instance_name); diff --git a/src/daemon/daemon_rpc.cpp b/src/daemon/daemon_rpc.cpp index 873f117a4b..7239746a53 100644 --- a/src/daemon/daemon_rpc.cpp +++ b/src/daemon/daemon_rpc.cpp @@ -608,6 +608,58 @@ grpc::Status mp::DaemonRpc::wait_ready( server); } +grpc::Status mp::DaemonRpc::migrate( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_migrate, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + +grpc::Status mp::DaemonRpc::list_network_hosts( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_list_network_hosts, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + +grpc::Status mp::DaemonRpc::add_known_host( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_add_known_host, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + +grpc::Status mp::DaemonRpc::remove_known_host( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + return verify_client_and_dispatch_operation(std::bind(&DaemonRpc::on_remove_known_host, + this, + std::placeholders::_1, + std::placeholders::_2, + std::placeholders::_3), + client_cert_from(context), + server); +} + grpc::Status mp::DaemonRpc::zones(grpc::ServerContext* context, grpc::ServerReaderWriter* server) { diff --git a/src/daemon/daemon_rpc.h b/src/daemon/daemon_rpc.h index 957db01709..ccff93e52c 100644 --- a/src/daemon/daemon_rpc.h +++ b/src/daemon/daemon_rpc.h @@ -162,6 +162,21 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl void on_wait_ready(const WaitReadyRequest* request, grpc::ServerReaderWriter* server, DaemonRpcContext* context); + void on_migrate(const MigrateRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); + void on_list_network_hosts( + const ListNetworkHostsRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); + void on_add_known_host( + const AddKnownHostRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); + void on_remove_known_host( + const RemoveKnownHostRequest* request, + grpc::ServerReaderWriter* server, + DaemonRpcContext* context); void on_zones(const ZonesRequest* request, grpc::ServerReaderWriter* server, DaemonRpcContext* context); @@ -312,6 +327,17 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl grpc::Status wait_ready( grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; + grpc::Status migrate(grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; + grpc::Status list_network_hosts( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; + grpc::Status add_known_host( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; + grpc::Status remove_known_host( + grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; grpc::Status zones(grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; grpc::Status zones_state( diff --git a/src/daemon/llm_dispatcher.cpp b/src/daemon/llm_dispatcher.cpp index cb57103baf..05b4819341 100644 --- a/src/daemon/llm_dispatcher.cpp +++ b/src/daemon/llm_dispatcher.cpp @@ -88,6 +88,12 @@ bool mp::LlmDispatcher::has_instance(const std::string& instance_id) const return llm_service && llm_service->has_instance(instance_id); } +std::optional mp::LlmDispatcher::instance_info( + const std::string& instance_id) const +{ + return llm_service ? llm_service->instance_info(instance_id) : std::nullopt; +} + template void mp::LlmDispatcher::run_sync(DaemonRpcContext* context, Work&& work) { diff --git a/src/daemon/llm_dispatcher.h b/src/daemon/llm_dispatcher.h index ac4d7cee03..6263500ad8 100644 --- a/src/daemon/llm_dispatcher.h +++ b/src/daemon/llm_dispatcher.h @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -48,9 +49,10 @@ class LlmDispatcher : public QObject ~LlmDispatcher() override; void unload_instances_blocking(const std::vector& instance_ids); - // Fast, mutex-protected lookup safe to call from any thread (unlike the slots + // Fast, mutex-protected lookups safe to call from any thread (unlike the slots // above, which must run on this object's own thread). bool has_instance(const std::string& instance_id) const; + std::optional instance_info(const std::string& instance_id) const; public slots: void shutdown(); diff --git a/src/llm/llm_service.cpp b/src/llm/llm_service.cpp index 44c44066bc..516f15eac6 100644 --- a/src/llm/llm_service.cpp +++ b/src/llm/llm_service.cpp @@ -1356,6 +1356,29 @@ bool mp::LlmService::has_instance(const std::string& instance_id) const return sessions.find(instance_id) != sessions.end(); } +std::optional mp::LlmService::instance_info(const std::string& instance_id) const +{ + std::lock_guard lock{mutex}; + auto it = sessions.find(instance_id); + if (it == sessions.end()) + return std::nullopt; + + const auto& session = it->second; + LoadedModelInfo info; + info.set_instance_id(session.instance_id); + info.set_model_id(session.model_id); + info.set_openai_id(session.openai_id); + info.set_backend(session.backend); + info.set_path(session.path); + info.set_port(static_cast(session.port)); + info.set_memory_claimed(static_cast(session.memory.in_bytes())); + info.set_max_tokens(session.max_tokens); + info.set_ctx_size(session.ctx_size); + info.set_intent(session.intent); + info.set_intent_role(session.intent_role); + return info; +} + std::optional mp::LlmService::session_by_openai_id(const std::string& openai_id) { std::lock_guard lock{mutex}; diff --git a/src/llm/llm_service.h b/src/llm/llm_service.h index c3523f81d6..fcc7310914 100644 --- a/src/llm/llm_service.h +++ b/src/llm/llm_service.h @@ -129,6 +129,9 @@ class LlmService : public QObject std::optional session_by_openai_id(const std::string& openai_id); bool is_loaded(const std::string& model_id) const; bool has_instance(const std::string& instance_id) const; + // A snapshot of one session's fields (for migration's "redefine the same LLM on the + // target" step), or nullopt if instance_id isn't currently loaded. + std::optional instance_info(const std::string& instance_id) const; private: enum class BackendKind diff --git a/src/platform/CMakeLists.txt b/src/platform/CMakeLists.txt index d8b4a4f282..f8d7f37954 100644 --- a/src/platform/CMakeLists.txt +++ b/src/platform/CMakeLists.txt @@ -16,14 +16,24 @@ function(add_target TARGET_NAME) if(LINUX) add_library(${TARGET_NAME} STATIC platform_linux.cpp - platform_unix.cpp) + platform_unix.cpp + mdns_service.cpp + mdns_service_linux.cpp) + + find_package(PkgConfig REQUIRED) + # Arch's avahi package ships no separate avahi-common.pc — avahi-client.pc's own Libs: + # already includes -lavahi-common, so only avahi-client needs to be requested here. + pkg_check_modules(AVAHI REQUIRED IMPORTED_TARGET avahi-client) target_link_libraries(${TARGET_NAME} - logger_linux) + logger_linux + PkgConfig::AVAHI) elseif(MSVC) add_library(${TARGET_NAME} STATIC platform_proprietary.cpp - platform_win.cpp) + platform_win.cpp + mdns_service.cpp + mdns_service_noop.cpp) qt6_disable_unicode_defines(${TARGET_NAME}) target_link_libraries(${TARGET_NAME} jsoncpp_static @@ -36,7 +46,11 @@ function(add_target TARGET_NAME) add_library(${TARGET_NAME} STATIC platform_osx.cpp platform_proprietary.cpp - platform_unix.cpp) + platform_unix.cpp + mdns_service.cpp + mdns_service_macos.cpp) + # dns_sd.h's classic API (DNSServiceRegister/Browse/Resolve) ships as part of libSystem; + # no extra framework to link against. target_link_libraries(${TARGET_NAME} shared_macos) endif() diff --git a/src/platform/mdns_service.cpp b/src/platform/mdns_service.cpp new file mode 100644 index 0000000000..75869607ea --- /dev/null +++ b/src/platform/mdns_service.cpp @@ -0,0 +1,25 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include + +namespace mp = multipass; + +mp::MdnsService::MdnsService() +{ + qRegisterMetaType(); +} diff --git a/src/platform/mdns_service_linux.cpp b/src/platform/mdns_service_linux.cpp new file mode 100644 index 0000000000..422d9e0b9e --- /dev/null +++ b/src/platform/mdns_service_linux.cpp @@ -0,0 +1,320 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace mp = multipass; +namespace mpl = multipass::logging; + +namespace +{ +constexpr auto category = "mdns"; +constexpr auto service_type = "_elp._tcp"; +// Purely informational: nothing actually listens here. Migration itself talks to a target's +// own local elp CLI over SSH, not to a peer elpd directly — see Daemon::migrate. +constexpr std::uint16_t advertised_port = 7773; + +std::string txt_value(AvahiStringList* txt, const char* key) +{ + if (!txt) + return {}; + auto* node = avahi_string_list_find(txt, key); + if (!node) + return {}; + char* k = nullptr; + char* v = nullptr; + size_t size = 0; + if (avahi_string_list_get_pair(node, &k, &v, &size) != 0) + return {}; + std::string result = v ? std::string(v, size) : std::string{}; + avahi_free(k); + avahi_free(v); + return result; +} + +class AvahiMdnsService : public mp::MdnsService +{ +public: + explicit AvahiMdnsService(mp::MdnsAdvertisement advertisement) + : advertisement{std::move(advertisement)} + { + } + + ~AvahiMdnsService() override + { + if (threaded_poll) + avahi_threaded_poll_stop(threaded_poll); + + // Resolvers may still be outstanding; free whatever's left before the client/poll go. + for (auto* resolver : live_resolvers) + avahi_service_resolver_free(resolver); + if (browser) + avahi_service_browser_free(browser); + if (entry_group) + avahi_entry_group_free(entry_group); + if (client) + avahi_client_free(client); + if (threaded_poll) + avahi_threaded_poll_free(threaded_poll); + } + + void start() override + { + threaded_poll = avahi_threaded_poll_new(); + if (!threaded_poll) + { + mpl::log(mpl::Level::warning, category, "avahi_threaded_poll_new failed"); + return; + } + + int error = 0; + client = avahi_client_new(avahi_threaded_poll_get(threaded_poll), + AVAHI_CLIENT_NO_FAIL, + &AvahiMdnsService::client_callback, + this, + &error); + if (!client) + { + mpl::log(mpl::Level::warning, category, "avahi_client_new failed: {}", avahi_strerror(error)); + avahi_threaded_poll_free(threaded_poll); + threaded_poll = nullptr; + return; + } + + if (avahi_threaded_poll_start(threaded_poll) != 0) + { + mpl::log(mpl::Level::warning, category, "avahi_threaded_poll_start failed"); + avahi_client_free(client); + client = nullptr; + avahi_threaded_poll_free(threaded_poll); + threaded_poll = nullptr; + } + } + +private: + // --- Advertising --------------------------------------------------------------------- + + void create_services() + { + entry_group = avahi_entry_group_new(client, &AvahiMdnsService::entry_group_callback, this); + if (!entry_group) + { + mpl::log(mpl::Level::warning, + category, + "avahi_entry_group_new failed: {}", + avahi_strerror(avahi_client_errno(client))); + return; + } + + const auto label = advertisement.label.empty() ? advertisement.host_name : advertisement.label; + const auto host_name_txt = fmt::format("host_name={}", advertisement.host_name); + const auto host_os_txt = fmt::format("host_os={}", advertisement.host_os); + const auto host_arch_txt = fmt::format("host_arch={}", advertisement.host_arch); + const auto backend_txt = fmt::format("backend={}", advertisement.backend); + + auto ret = avahi_entry_group_add_service(entry_group, + AVAHI_IF_UNSPEC, + AVAHI_PROTO_UNSPEC, + AvahiPublishFlags{}, + label.c_str(), + service_type, + nullptr, + nullptr, + advertised_port, + host_name_txt.c_str(), + host_os_txt.c_str(), + host_arch_txt.c_str(), + backend_txt.c_str(), + nullptr); + if (ret < 0) + { + mpl::log(mpl::Level::warning, + category, + "avahi_entry_group_add_service failed: {}", + avahi_strerror(ret)); + return; + } + + ret = avahi_entry_group_commit(entry_group); + if (ret < 0) + mpl::log(mpl::Level::warning, category, "avahi_entry_group_commit failed: {}", avahi_strerror(ret)); + } + + static void entry_group_callback(AvahiEntryGroup*, AvahiEntryGroupState state, void*) + { + if (state == AVAHI_ENTRY_GROUP_COLLISION) + mpl::log(mpl::Level::warning, + category, + "mDNS service name collision advertising this host; not retrying in v1"); + else if (state == AVAHI_ENTRY_GROUP_FAILURE) + mpl::log(mpl::Level::warning, category, "mDNS entry group failure"); + } + + // --- Browsing -------------------------------------------------------------------------- + + void create_browser() + { + browser = avahi_service_browser_new(client, + AVAHI_IF_UNSPEC, + AVAHI_PROTO_UNSPEC, + service_type, + nullptr, + AvahiLookupFlags{}, + &AvahiMdnsService::browse_callback, + this); + if (!browser) + mpl::log(mpl::Level::warning, + category, + "avahi_service_browser_new failed: {}", + avahi_strerror(avahi_client_errno(client))); + } + + static void browse_callback(AvahiServiceBrowser*, + AvahiIfIndex interface, + AvahiProtocol protocol, + AvahiBrowserEvent event, + const char* name, + const char* type, + const char* domain, + AvahiLookupResultFlags, + void* userdata) + { + auto* self = static_cast(userdata); + if (event == AVAHI_BROWSER_NEW) + { + // Skip our own advertisement. + if (self->advertisement.label == name || + (self->advertisement.label.empty() && self->advertisement.host_name == name)) + return; + + auto* resolver = avahi_service_resolver_new(self->client, + interface, + protocol, + name, + type, + domain, + AVAHI_PROTO_UNSPEC, + AvahiLookupFlags{}, + &AvahiMdnsService::resolve_callback, + self); + if (resolver) + self->live_resolvers.insert(resolver); + } + else if (event == AVAHI_BROWSER_REMOVE) + { + emit self->host_removed(std::string(name)); + } + else if (event == AVAHI_BROWSER_FAILURE) + { + mpl::log(mpl::Level::warning, + category, + "mDNS browse failure: {}", + avahi_strerror(avahi_client_errno(self->client))); + } + } + + static void resolve_callback(AvahiServiceResolver* r, + AvahiIfIndex, + AvahiProtocol, + AvahiResolverEvent event, + const char* name, + const char*, + const char*, + const char* host_name, + const AvahiAddress* address, + uint16_t, + AvahiStringList* txt, + AvahiLookupResultFlags, + void* userdata) + { + auto* self = static_cast(userdata); + + if (event == AVAHI_RESOLVER_FOUND) + { + char address_str[AVAHI_ADDRESS_STR_MAX]; + avahi_address_snprint(address_str, sizeof(address_str), address); + + mp::MdnsHostInfo info; + info.label = name ? name : ""; + info.address = address_str; + info.host_name = txt_value(txt, "host_name"); + if (info.host_name.empty()) + info.host_name = host_name ? host_name : ""; + info.host_os = txt_value(txt, "host_os"); + info.host_arch = txt_value(txt, "host_arch"); + info.backend = txt_value(txt, "backend"); + + emit self->host_discovered(info); + } + else + { + mpl::log(mpl::Level::debug, category, "mDNS resolve failed for {}", name ? name : ""); + } + + self->live_resolvers.erase(r); + avahi_service_resolver_free(r); + } + + static void client_callback(AvahiClient* c, AvahiClientState state, void* userdata) + { + auto* self = static_cast(userdata); + self->client = c; + + switch (state) + { + case AVAHI_CLIENT_S_RUNNING: + self->create_services(); + self->create_browser(); + break; + case AVAHI_CLIENT_FAILURE: + mpl::log(mpl::Level::warning, category, "mDNS client failure: {}", avahi_strerror(avahi_client_errno(c))); + break; + case AVAHI_CLIENT_S_COLLISION: + case AVAHI_CLIENT_S_REGISTERING: + if (self->entry_group) + avahi_entry_group_reset(self->entry_group); + break; + case AVAHI_CLIENT_CONNECTING: + break; + } + } + + mp::MdnsAdvertisement advertisement; + AvahiThreadedPoll* threaded_poll{nullptr}; + AvahiClient* client{nullptr}; + AvahiEntryGroup* entry_group{nullptr}; + AvahiServiceBrowser* browser{nullptr}; + std::unordered_set live_resolvers; +}; +} // namespace + +std::unique_ptr mp::make_mdns_service(MdnsAdvertisement advertisement) +{ + return std::make_unique(std::move(advertisement)); +} diff --git a/src/platform/mdns_service_macos.cpp b/src/platform/mdns_service_macos.cpp new file mode 100644 index 0000000000..c30798afcf --- /dev/null +++ b/src/platform/mdns_service_macos.cpp @@ -0,0 +1,257 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include +#include +#include + +#include + +#include + +#include +#include + +namespace mp = multipass; +namespace mpl = multipass::logging; + +namespace +{ +constexpr auto category = "mdns"; +constexpr auto service_type = "_elp._tcp"; +// Purely informational: nothing actually listens here. Migration itself talks to a target's +// own local elp CLI over SSH, not to a peer elpd directly — see Daemon::migrate. +constexpr std::uint16_t advertised_port = 7773; + +// Wraps one DNSServiceRef with the QSocketNotifier that drives it from Qt's own event loop — +// dns_sd's classic API hands back a plain fd (DNSServiceRefSockFD) to poll/select on and expects +// DNSServiceProcessResult() to be called when it's readable, which is a much more natural fit +// for Qt than Avahi's API (see mdns_service_linux.cpp's dedicated AvahiThreadedPoll thread) — +// no separate thread needed here. +class ServiceRefWatcher : public QObject +{ +public: + ServiceRefWatcher(DNSServiceRef ref, QObject* parent) : QObject{parent}, ref{ref} + { + notifier = std::make_unique(DNSServiceRefSockFD(ref), + QSocketNotifier::Read, + this); + connect(notifier.get(), &QSocketNotifier::activated, this, [this] { + const auto err = DNSServiceProcessResult(this->ref); + if (err != kDNSServiceErr_NoError) + mpl::log(mpl::Level::debug, category, "DNSServiceProcessResult error: {}", err); + }); + } + + ~ServiceRefWatcher() override + { + DNSServiceRefDeallocate(ref); + } + +private: + DNSServiceRef ref; + std::unique_ptr notifier; +}; + +class BonjourMdnsService : public mp::MdnsService +{ +public: + explicit BonjourMdnsService(mp::MdnsAdvertisement advertisement) + : advertisement{std::move(advertisement)} + { + } + + void start() override + { + register_service(); + browse(); + } + +private: + void register_service() + { + TXTRecordRef txt; + TXTRecordCreate(&txt, 0, nullptr); + TXTRecordSetValue(&txt, + "host_name", + static_cast(advertisement.host_name.size()), + advertisement.host_name.data()); + TXTRecordSetValue(&txt, + "host_os", + static_cast(advertisement.host_os.size()), + advertisement.host_os.data()); + TXTRecordSetValue(&txt, + "host_arch", + static_cast(advertisement.host_arch.size()), + advertisement.host_arch.data()); + TXTRecordSetValue(&txt, + "backend", + static_cast(advertisement.backend.size()), + advertisement.backend.data()); + + DNSServiceRef ref{nullptr}; + const auto label = advertisement.label.empty() ? advertisement.host_name : advertisement.label; + const auto err = DNSServiceRegister(&ref, + 0, + 0, + label.empty() ? nullptr : label.c_str(), + service_type, + nullptr, + nullptr, + htons(advertised_port), + TXTRecordGetLength(&txt), + TXTRecordGetBytesPtr(&txt), + &BonjourMdnsService::register_callback, + this); + TXTRecordDeallocate(&txt); + + if (err != kDNSServiceErr_NoError) + { + mpl::log(mpl::Level::warning, category, "DNSServiceRegister failed: {}", err); + return; + } + register_watcher = std::make_unique(ref, this); + } + + void browse() + { + DNSServiceRef ref{nullptr}; + const auto err = DNSServiceBrowse(&ref, + 0, + 0, + service_type, + nullptr, + &BonjourMdnsService::browse_callback, + this); + if (err != kDNSServiceErr_NoError) + { + mpl::log(mpl::Level::warning, category, "DNSServiceBrowse failed: {}", err); + return; + } + browse_watcher = std::make_unique(ref, this); + } + + static void register_callback(DNSServiceRef, + DNSServiceFlags, + DNSServiceErrorType error, + const char*, + const char*, + const char*, + void*) + { + if (error != kDNSServiceErr_NoError) + mpl::log(mpl::Level::warning, category, "DNSServiceRegister callback error: {}", error); + } + + static void browse_callback(DNSServiceRef, + DNSServiceFlags flags, + uint32_t interface_index, + DNSServiceErrorType error, + const char* name, + const char* type, + const char* domain, + void* userdata) + { + auto* self = static_cast(userdata); + if (error != kDNSServiceErr_NoError) + { + mpl::log(mpl::Level::warning, category, "DNSServiceBrowse callback error: {}", error); + return; + } + + if (flags & kDNSServiceFlagsAdd) + { + if (self->advertisement.label == name || + (self->advertisement.label.empty() && self->advertisement.host_name == name)) + return; // skip our own advertisement + + DNSServiceRef resolve_ref{nullptr}; + const auto err = DNSServiceResolve(&resolve_ref, + 0, + interface_index, + name, + type, + domain, + &BonjourMdnsService::resolve_callback, + self); + if (err == kDNSServiceErr_NoError) + self->resolve_watchers[name] = + std::make_unique(resolve_ref, self); + } + else + { + self->resolve_watchers.erase(name); + emit self->host_removed(std::string(name)); + } + } + + static void resolve_callback(DNSServiceRef, + DNSServiceFlags, + uint32_t, + DNSServiceErrorType error, + const char* full_name, + const char* host_target, + uint16_t, + uint16_t txt_len, + const unsigned char* txt_record, + void* userdata) + { + auto* self = static_cast(userdata); + + if (error != kDNSServiceErr_NoError) + { + mpl::log(mpl::Level::debug, category, "DNSServiceResolve error: {}", error); + return; + } + + auto txt_value = [&](const char* key) -> std::string { + uint8_t value_len = 0; + const auto* value = static_cast( + TXTRecordGetValuePtr(txt_len, txt_record, key, &value_len)); + return value ? std::string(value, value_len) : std::string{}; + }; + + mp::MdnsHostInfo info; + info.label = full_name ? full_name : ""; + info.address = host_target ? host_target : ""; // an mDNS .local hostname, directly SSH-able + info.host_name = txt_value("host_name"); + info.host_os = txt_value("host_os"); + info.host_arch = txt_value("host_arch"); + info.backend = txt_value("backend"); + + emit self->host_discovered(info); + + // One-shot: this backend doesn't keep resolving after the first answer, unlike browse. + // Deferred via invokeMethod rather than erased here directly — erasing the + // ServiceRefWatcher that owns the very DNSServiceRef this callback is running under + // would destroy its QSocketNotifier from inside its own activated handler. + const std::string key = full_name ? full_name : ""; + QMetaObject::invokeMethod( + self, [self, key] { self->resolve_watchers.erase(key); }, Qt::QueuedConnection); + } + + mp::MdnsAdvertisement advertisement; + std::unique_ptr register_watcher; + std::unique_ptr browse_watcher; + std::unordered_map> resolve_watchers; +}; +} // namespace + +std::unique_ptr mp::make_mdns_service(MdnsAdvertisement advertisement) +{ + return std::make_unique(std::move(advertisement)); +} diff --git a/src/platform/mdns_service_noop.cpp b/src/platform/mdns_service_noop.cpp new file mode 100644 index 0000000000..2f77f04335 --- /dev/null +++ b/src/platform/mdns_service_noop.cpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) Elemento. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * 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 . + * + */ + +#include + +namespace mp = multipass; + +namespace +{ +// No mDNS backend on this platform (or the real one failed to initialize): the migration +// screen's host list simply has no "discovered" entries, and the user falls back to the +// always-available "known hosts" manual list. +class NoopMdnsService : public mp::MdnsService +{ +public: + void start() override + { + } +}; +} // namespace + +std::unique_ptr mp::make_mdns_service(MdnsAdvertisement) +{ + return std::make_unique(); +} diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index 8ed44b3502..e32644c22e 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -55,6 +55,10 @@ service Rpc { rpc zones_state (stream ZonesStateRequest) returns (stream ZonesStateReply); rpc cache_info (stream CacheInfoRequest) returns (stream CacheInfoReply); rpc cache_delete (stream CacheDeleteRequest) returns (stream CacheDeleteReply); + rpc migrate (stream MigrateRequest) returns (stream MigrateReply); + rpc list_network_hosts (stream ListNetworkHostsRequest) returns (stream ListNetworkHostsReply); + rpc add_known_host (stream AddKnownHostRequest) returns (stream AddKnownHostReply); + rpc remove_known_host (stream RemoveKnownHostRequest) returns (stream RemoveKnownHostReply); rpc find_models (stream FindModelsRequest) returns (stream FindModelsReply); rpc pull_model (stream PullModelRequest) returns (stream PullModelReply); @@ -700,6 +704,9 @@ message DaemonInfoReply { // Cumulative host interface counters (loopback excluded). Clients derive rates. uint64 network_rx_bytes = 18; uint64 network_tx_bytes = 19; + // Driver name (config->factory->get_backend_directory_name(), e.g. "qemu"), advertised + // over mDNS alongside host_os/host_arch so peers can be told apart at a glance. + string backend = 20; } message ResourceClaimInfo { @@ -709,6 +716,62 @@ message ResourceClaimInfo { uint32 cpus = 4; } +message MigrateRequest { + string name = 1; // a VM instance name or an intent name + string target = 2; // "user@host", reachable over SSH with elp already installed there + bool copy = 3; // keep the source instance(s)/session(s) instead of deleting them + int32 verbosity_level = 4; +} + +message MigrateReply { + string log_line = 1; + string reply_message = 2; +} + +// One host on the network (or manually added), as shown in the GUI's migration target +// picker. Discovered hosts come from mDNS browsing; known hosts are the persisted +// manually-added fallback (the plan's own cross-subnet/no-multicast escape hatch). +message NetworkHost { + string label = 1; // friendly name: the mDNS service name, or the label given to add_known_host + string target = 2; // "user@host"; only set once a user has been supplied (see GUI flow) + string host_name = 3; + string host_os = 4; + string host_arch = 5; + string backend = 6; + string address = 7; // resolved IP/hostname (discovered hosts only) + bool discovered = 8; // true: found via mDNS; false: manually added via add_known_host +} + +message ListNetworkHostsRequest { + int32 verbosity_level = 1; +} + +message ListNetworkHostsReply { + repeated NetworkHost hosts = 1; + string log_line = 2; +} + +message AddKnownHostRequest { + string label = 1; + string target = 2; // "user@host" + int32 verbosity_level = 3; +} + +message AddKnownHostReply { + string log_line = 1; + string reply_message = 2; +} + +message RemoveKnownHostRequest { + string label = 1; + int32 verbosity_level = 2; +} + +message RemoveKnownHostReply { + string log_line = 1; + string reply_message = 2; +} + message WaitReadyRequest { int32 verbosity_level = 1; } diff --git a/src/utils/vm_specs.cpp b/src/utils/vm_specs.cpp index 00549b6921..8e95ff778c 100644 --- a/src/utils/vm_specs.cpp +++ b/src/utils/vm_specs.cpp @@ -46,6 +46,9 @@ void mp::tag_invoke(const boost::json::value_from_tag&, {"clone_count", specs.clone_count}, {"zone", specs.zone}, {"service_id", specs.service_id}, + {"image", specs.image}, + {"cloud_init_user_data", specs.cloud_init_user_data}, + {"remote_name", specs.remote_name}, }; } @@ -93,5 +96,8 @@ mp::VMSpecs mp::tag_invoke(const boost::json::value_to_tag&, lookup_or(json, "clone_count", 0), lookup_or(json, "zone", az_manager.get_default_zone_name()), service_id, + lookup_or(json, "image", {}), + lookup_or(json, "cloud_init_user_data", {}), + lookup_or(json, "remote_name", {}), }; } From c65f3f894fdd4b25f452ae0bc25eb7fd77b596d2 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 20:41:53 +0200 Subject: [PATCH 24/27] Enable AUTOMOC in src/platform for MdnsService's Q_OBJECT machinery Linking elpd failed with undefined vtable/metaObject/qt_metacall/signal symbols for multipass::MdnsService: it's a Q_OBJECT class (like multipass::Process, also declared under include/multipass/), but unlike src/daemon and src/llm (which both set CMAKE_AUTOMOC ON), src/platform never enabled AUTOMOC, so moc never ran on mdns_service.h and none of its signal/vtable implementations were ever generated. --- src/platform/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/platform/CMakeLists.txt b/src/platform/CMakeLists.txt index f8d7f37954..08b8619bd8 100644 --- a/src/platform/CMakeLists.txt +++ b/src/platform/CMakeLists.txt @@ -13,6 +13,11 @@ # along with this program. If not, see . function(add_target TARGET_NAME) + # mdns_service.h (include/multipass/mdns_service.h) is a Q_OBJECT class; its signal/vtable/ + # metaobject implementations only get generated for targets that moc it, which needs AUTOMOC + # enabled here (unlike src/daemon or src/llm, this directory never turned it on before). + set(CMAKE_AUTOMOC ON) + if(LINUX) add_library(${TARGET_NAME} STATIC platform_linux.cpp From fb023c1af0caf6ce8674711b6d669ef34784dc5d Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 20:58:48 +0200 Subject: [PATCH 25/27] Set AUTOMOC as an explicit target property too, not just the variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (set(CMAKE_AUTOMOC ON) before add_library) should be sufficient on its own — it's the same pattern src/daemon and src/llm already use successfully — but the exact same undefined-vtable/metaObject link errors for MdnsService recurred after it, unmodified. Setting AUTOMOC directly via set_target_properties right after the target is created removes any possible ambiguity from directory/function variable scoping, in case that's what's actually going on here. Actually fix MdnsService moc generation: list the header as a source Both previous attempts (CMAKE_AUTOMOC variable, then the AUTOMOC target property) left the exact same undefined vtable/metaObject/signal symbols — AUTOMOC was never the problem, header discovery was. Found the real precedent: src/process/CMakeLists.txt already has a Q_OBJECT header outside its own directory (include/multipass/process/process.h) and explicitly lists it as a library source, rather than relying on it being #included by a tracked .cpp. AUTOMOC does not reliably moc a header that way — it needs to be an explicit source. Added ${CMAKE_SOURCE_DIR}/include/multipass/mdns_service.h to each platform variant's add_library() sources, mirroring that pattern exactly. --- src/platform/CMakeLists.txt | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/platform/CMakeLists.txt b/src/platform/CMakeLists.txt index 08b8619bd8..db306231b5 100644 --- a/src/platform/CMakeLists.txt +++ b/src/platform/CMakeLists.txt @@ -18,12 +18,20 @@ function(add_target TARGET_NAME) # enabled here (unlike src/daemon or src/llm, this directory never turned it on before). set(CMAKE_AUTOMOC ON) + # mdns_service.h is a Q_OBJECT class declared outside this directory + # (include/multipass/mdns_service.h). AUTOMOC reliably mocs a header like this only when + # it's an explicit source of the target — not merely #included by a tracked .cpp — mirroring + # how src/process/CMakeLists.txt lists include/multipass/process/process.h as a source of + # the `process` target for the exact same reason. + set(MDNS_SERVICE_HEADER ${CMAKE_SOURCE_DIR}/include/multipass/mdns_service.h) + if(LINUX) add_library(${TARGET_NAME} STATIC platform_linux.cpp platform_unix.cpp mdns_service.cpp - mdns_service_linux.cpp) + mdns_service_linux.cpp + ${MDNS_SERVICE_HEADER}) find_package(PkgConfig REQUIRED) # Arch's avahi package ships no separate avahi-common.pc — avahi-client.pc's own Libs: @@ -38,7 +46,8 @@ function(add_target TARGET_NAME) platform_proprietary.cpp platform_win.cpp mdns_service.cpp - mdns_service_noop.cpp) + mdns_service_noop.cpp + ${MDNS_SERVICE_HEADER}) qt6_disable_unicode_defines(${TARGET_NAME}) target_link_libraries(${TARGET_NAME} jsoncpp_static @@ -53,13 +62,18 @@ function(add_target TARGET_NAME) platform_proprietary.cpp platform_unix.cpp mdns_service.cpp - mdns_service_macos.cpp) + mdns_service_macos.cpp + ${MDNS_SERVICE_HEADER}) # dns_sd.h's classic API (DNSServiceRegister/Browse/Resolve) ships as part of libSystem; # no extra framework to link against. target_link_libraries(${TARGET_NAME} shared_macos) endif() + # Belt-and-suspenders alongside the CMAKE_AUTOMOC variable set above: this sets the AUTOMOC + # property directly on the target itself, leaving no ambiguity about variable scoping. + set_target_properties(${TARGET_NAME} PROPERTIES AUTOMOC ON) + foreach(BACKEND IN LISTS MULTIPASS_BACKENDS) string(TOUPPER ${BACKEND}_ENABLED DEF) target_compile_definitions(${TARGET_NAME} PRIVATE -D${DEF}) From 5946beea046f0f6b076976f2ac09da325bbaa16f Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 22:05:07 +0200 Subject: [PATCH 26/27] Add --identity to elp migrate, for ssh key auth as elpd runs as root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world testing hit "Permission denied (publickey,password)": elpd runs as root (systemd unit has no User=), so ssh/rsync child processes it spawns use root's own ~/.ssh, not whichever user actually ran `elp migrate` — root's identity is very unlikely to already be authorized on a migration target. Add an optional identity_file (MigrateRequest field 5, `elp migrate --identity `, threaded through as `ssh -i`/`rsync -e "ssh -i ..."`) so a normal user's own key can be used instead of needing a separate one set up for root. Also added the same field to the GUI's migrate dialog. --- src/client/cli/cmd/migrate.cpp | 13 +++- src/client/cli/cmd/migrate.h | 1 + src/client/gui/lib/grpc_client.dart | 9 ++- .../gui/lib/migrate/migrate_screen.dart | 21 +++++- src/daemon/daemon.cpp | 65 +++++++++++-------- src/rpc/multipass.proto | 6 ++ 6 files changed, 85 insertions(+), 30 deletions(-) diff --git a/src/client/cli/cmd/migrate.cpp b/src/client/cli/cmd/migrate.cpp index bf4d96e466..5adfe38f4e 100644 --- a/src/client/cli/cmd/migrate.cpp +++ b/src/client/cli/cmd/migrate.cpp @@ -34,6 +34,12 @@ const QCommandLineOption to_option{"to", "user@host"}; const QCommandLineOption copy_option{ "copy", "Keep the source instance(s)/session(s) instead of deleting them once migrated."}; +const QCommandLineOption identity_option{ + "identity", + "Private key file for ssh/rsync to authenticate with (\"ssh -i\"). elpd runs as root, so " + "without this, ssh falls back to root's own key, which is likely not authorized on the " + "target — pass your own (e.g. ~/.ssh/id_ed25519) to avoid needing a separate key for root.", + "path"}; } // namespace mp::ReturnCodeVariant cmd::Migrate::run(ArgParser* parser) @@ -46,6 +52,7 @@ mp::ReturnCodeVariant cmd::Migrate::run(ArgParser* parser) request.set_name(instance_name); request.set_target(target.toStdString()); request.set_copy(copy); + request.set_identity_file(identity_file.toStdString()); request.set_verbosity_level(parser->verbosityLevel()); AnimatedSpinner spinner{cout}; @@ -90,7 +97,8 @@ QString cmd::Migrate::description() const "the same image/cloud-init (or, for an LLM intent member, reloaded there), and " "any mounts are synced across; the source is then deleted unless --copy is given.\n\n" " elp migrate my-instance --to user@host\n" - " elp migrate my-intent --to user@host --copy"); + " elp migrate my-intent --to user@host --copy\n" + " elp migrate my-instance --to user@host --identity ~/.ssh/id_ed25519"); } mp::ParseCode cmd::Migrate::parse_args(ArgParser* parser) @@ -98,6 +106,7 @@ mp::ParseCode cmd::Migrate::parse_args(ArgParser* parser) parser->addPositionalArgument("name", "Name of the instance or intent to migrate", ""); parser->addOption(to_option); parser->addOption(copy_option); + parser->addOption(identity_option); const auto status = parser->commandParse(this); if (status != ParseCode::Ok) @@ -118,6 +127,8 @@ mp::ParseCode cmd::Migrate::parse_args(ArgParser* parser) } target = parser->value(to_option); copy = parser->isSet(copy_option); + if (parser->isSet(identity_option)) + identity_file = parser->value(identity_option); return ParseCode::Ok; } diff --git a/src/client/cli/cmd/migrate.h b/src/client/cli/cmd/migrate.h index 0d2eaf3778..413c858a38 100644 --- a/src/client/cli/cmd/migrate.h +++ b/src/client/cli/cmd/migrate.h @@ -36,6 +36,7 @@ class Migrate final : public Command std::string instance_name; QString target; + QString identity_file; bool copy{false}; }; } // namespace multipass::cmd diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index 53018fb6d4..2f4d3040cb 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -420,10 +420,15 @@ class GrpcClient { ); } - Future migrate(String name, String target, {bool copy = false}) { + Future migrate( + String name, + String target, { + bool copy = false, + String identityFile = '', + }) { return doRpc( _client.migrate, - MigrateRequest(name: name, target: target, copy: copy), + MigrateRequest(name: name, target: target, copy: copy, identityFile: identityFile), ); } diff --git a/src/client/gui/lib/migrate/migrate_screen.dart b/src/client/gui/lib/migrate/migrate_screen.dart index 1f4ef7a981..5f44917d8c 100644 --- a/src/client/gui/lib/migrate/migrate_screen.dart +++ b/src/client/gui/lib/migrate/migrate_screen.dart @@ -260,6 +260,7 @@ Future showMigrateDialog( String? selectedLabel; final customTargetController = TextEditingController(); final usernameController = TextEditingController(); + final identityController = TextEditingController(); var copy = false; String? error; @@ -316,6 +317,18 @@ Future showMigrateDialog( ), ], const SizedBox(height: 12), + TextFormField( + controller: identityController, + decoration: const InputDecoration( + labelText: 'SSH identity file (optional)', + hintText: '~/.ssh/id_ed25519', + helperText: + 'elpd runs as root, so ssh otherwise uses root\'s own key, which is ' + 'likely not authorized on the target — point this at your own key ' + 'instead.', + ), + ), + const SizedBox(height: 12), CheckboxListTile( controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, @@ -358,7 +371,12 @@ Future showMigrateDialog( } Navigator.pop(dialogContext); - final op = ref.read(grpcClientProvider).migrate(name, target, copy: copy); + final op = ref.read(grpcClientProvider).migrate( + name, + target, + copy: copy, + identityFile: identityController.text.trim(), + ); ref.read(notificationsProvider.notifier).addOperation( op, loading: 'Migrating "$name" to $target...', @@ -385,4 +403,5 @@ Future showMigrateDialog( customTargetController.dispose(); usernameController.dispose(); + identityController.dispose(); } diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index 20c202c6e7..74a062daac 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -3874,15 +3874,13 @@ struct RemoteResult RemoteResult run_ssh_raw(const std::string& target, const std::string& remote_command, const std::string& stdin_data, - int timeout_ms) -{ - const QStringList args{"-o", - "BatchMode=yes", - "-o", - "StrictHostKeyChecking=accept-new", - "-T", - QString::fromStdString(target), - QString::fromStdString(remote_command)}; + int timeout_ms, + const std::string& identity_file = {}) +{ + QStringList args{"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new"}; + if (!identity_file.empty()) + args << "-i" << QString::fromStdString(identity_file); + args << "-T" << QString::fromStdString(target) << QString::fromStdString(remote_command); auto process = mp::platform::make_process(mp::simple_process_spec("ssh", args)); process->start(); if (!process->wait_for_started(timeout_ms)) @@ -3904,9 +3902,10 @@ RemoteResult run_ssh_raw(const std::string& target, RemoteResult run_ssh(const std::string& target, const std::vector& remote_args, const std::string& stdin_data = {}, - int timeout_ms = 120000) + int timeout_ms = 120000, + const std::string& identity_file = {}) { - return run_ssh_raw(target, join_shell_command(remote_args), stdin_data, timeout_ms); + return run_ssh_raw(target, join_shell_command(remote_args), stdin_data, timeout_ms, identity_file); } // rsync's own remote-path argument (user@host:/path) is parsed by rsync itself, not passed @@ -3914,12 +3913,17 @@ RemoteResult run_ssh(const std::string& target, RemoteResult run_rsync(const std::string& source_path, const std::string& target, const std::string& dest_path, - int timeout_ms) + int timeout_ms, + const std::string& identity_file = {}) { + auto ssh_command = std::string{"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"}; + if (!identity_file.empty()) + ssh_command += " -i " + identity_file; // no spaces expected in a key path; not quoted + const QStringList args{ "-az", "-e", - "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", + QString::fromStdString(ssh_command), "--mkpath", QString::fromStdString(source_path) + "/", QString::fromStdString(target) + ":" + QString::fromStdString(dest_path)}; @@ -3973,6 +3977,7 @@ std::string image_arg_for(const MigrateVmMember& member) // rsynced their host-side source directories there — see perform_migration). bool migrate_standalone_vm(const MigrateVmMember& member, const std::string& target, + const std::string& identity_file, MigrationOutcome& outcome) { std::vector args{"elp", @@ -3994,7 +3999,7 @@ bool migrate_standalone_vm(const MigrateVmMember& member, outcome.log_lines.push_back( fmt::format("Relaunching \"{}\" on {}...", member.instance_name, target)); - auto result = run_ssh(target, args, member.cloud_init_user_data, 600000); + auto result = run_ssh(target, args, member.cloud_init_user_data, 600000, identity_file); if (!result.ok) { outcome.message = fmt::format("Failed to launch \"{}\" on {}: {}", @@ -4008,7 +4013,8 @@ bool migrate_standalone_vm(const MigrateVmMember& member, { outcome.log_lines.push_back( fmt::format("Syncing mount {} -> {}...", mount.get_source_path(), target)); - auto rsync_result = run_rsync(mount.get_source_path(), target, mount.get_source_path(), 1800000); + auto rsync_result = run_rsync( + mount.get_source_path(), target, mount.get_source_path(), 1800000, identity_file); if (!rsync_result.ok) { outcome.message = fmt::format("Failed to sync mount \"{}\": {}", @@ -4021,7 +4027,8 @@ bool migrate_standalone_vm(const MigrateVmMember& member, target, {"elp", "mount", mount.get_source_path(), fmt::format("{}:{}", member.instance_name, target_path)}, {}, - 60000); + 60000, + identity_file); if (!mount_result.ok) { outcome.message = fmt::format( @@ -4045,6 +4052,7 @@ bool migrate_intent(const std::string& intent_name, const std::vector& vm_members, const std::vector& llm_members, const std::string& target, + const std::string& identity_file, MigrationOutcome& outcome) { std::vector remote_tmp_files; @@ -4064,7 +4072,8 @@ bool migrate_intent(const std::string& intent_name, auto put = run_ssh_raw(target, fmt::format("cat > {}", shell_quote(cloud_init_path)), member.cloud_init_user_data, - 60000); + 60000, + identity_file); if (!put.ok) { outcome.message = fmt::format("Failed to stage cloud-init for \"{}\" on {}: {}", @@ -4093,13 +4102,13 @@ bool migrate_intent(const std::string& intent_name, } outcome.log_lines.push_back(fmt::format("Creating intent \"{}\" on {}...", intent_name, target)); - auto result = run_ssh(target, args, {}, 900000); + auto result = run_ssh(target, args, {}, 900000, identity_file); if (!remote_tmp_files.empty()) { std::vector rm_args{"rm", "-f"}; rm_args.insert(rm_args.end(), remote_tmp_files.begin(), remote_tmp_files.end()); - run_ssh(target, rm_args, {}, 30000); // best-effort cleanup, failure not fatal + run_ssh(target, rm_args, {}, 30000, identity_file); // best-effort cleanup, failure not fatal } if (!result.ok) @@ -4116,8 +4125,8 @@ bool migrate_intent(const std::string& intent_name, const auto remote_instance = fmt::format("{}-{}", intent_name, member.role); outcome.log_lines.push_back( fmt::format("Syncing mount {} -> {}...", mount.get_source_path(), target)); - auto rsync_result = - run_rsync(mount.get_source_path(), target, mount.get_source_path(), 1800000); + auto rsync_result = run_rsync( + mount.get_source_path(), target, mount.get_source_path(), 1800000, identity_file); if (!rsync_result.ok) { outcome.message = fmt::format("Failed to sync mount \"{}\": {}", @@ -4130,7 +4139,8 @@ bool migrate_intent(const std::string& intent_name, target, {"elp", "mount", mount.get_source_path(), fmt::format("{}:{}", remote_instance, target_path)}, {}, - 60000); + 60000, + identity_file); if (!mount_result.ok) { outcome.message = fmt::format("Intent created on {}, but mounting \"{}\" for \"{}\" failed: {}", @@ -4149,6 +4159,7 @@ bool migrate_intent(const std::string& intent_name, MigrationOutcome perform_migration(const std::string& name, bool is_intent, const std::string& target, + const std::string& identity_file, std::vector vm_members, std::vector llm_members) { @@ -4161,13 +4172,13 @@ MigrationOutcome perform_migration(const std::string& name, // kinds of member together — this is what actually (re-)registers each member (VM or // LLM) into the target's own intents map; `elp llm load --intent` alone only tags the // session, it doesn't touch the registry (see Daemon::migrate's own doc comment). - if (!migrate_intent(name, vm_members, llm_members, target, outcome)) + if (!migrate_intent(name, vm_members, llm_members, target, identity_file, outcome)) return outcome; } else { assert(vm_members.size() == 1 && llm_members.empty()); - if (!migrate_standalone_vm(vm_members.front(), target, outcome)) + if (!migrate_standalone_vm(vm_members.front(), target, identity_file, outcome)) return outcome; } @@ -4184,6 +4195,7 @@ try { const auto& name = request->name(); const auto& target = request->target(); + const auto& identity_file = request->identity_file(); const auto copy = request->copy(); if (name.empty()) @@ -4280,8 +4292,9 @@ try // the daemon's own thread via QFutureWatcher::finished (as usual), since that's what touches // `intents`/`vm_instance_specs`. auto future = QtConcurrent::run( - [name, is_intent, target, vm_members, llm_members]() mutable { - return perform_migration(name, is_intent, target, std::move(vm_members), std::move(llm_members)); + [name, is_intent, target, identity_file, vm_members, llm_members]() mutable { + return perform_migration( + name, is_intent, target, identity_file, std::move(vm_members), std::move(llm_members)); }); auto* watcher = new QFutureWatcher(); diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index e32644c22e..20ba31da27 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -721,6 +721,12 @@ message MigrateRequest { string target = 2; // "user@host", reachable over SSH with elp already installed there bool copy = 3; // keep the source instance(s)/session(s) instead of deleting them int32 verbosity_level = 4; + // Private key file ssh/rsync should authenticate with (passed as `ssh -i`). elpd runs as + // root, so plain `ssh user@host` otherwise uses root's own ~/.ssh, not whichever user is + // actually running `elp migrate` — pointing this at that user's own key (e.g. + // ~/.ssh/id_ed25519) avoids needing a separate key set up for root. Optional: if empty, + // ssh falls back to its own normal key discovery (i.e. root's). + string identity_file = 5; } message MigrateReply { From 900426a85ec0dc2dc8fefaa8c5336aea031e4398 Mon Sep 17 00:00:00 2001 From: filippo-ferrando Date: Sat, 12 Sep 2026 22:29:55 +0200 Subject: [PATCH 27/27] Add a per-known-host default identity file, with a file-picker field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration Hosts' "Add host" dialog now also takes an optional SSH identity file, saved per host (KnownHost{target, identity_file}, persisted the same way) so it doesn't need retyping on every migration to that host. The migrate dialog pre-fills its own identity field from the selected host's saved default when you pick one from the dropdown (still editable, to override for just that migration) and defaults to empty — i.e. ssh's own normal key discovery — if neither is set. Both identity fields are backed by file_selector's openFile() (already a GUI dependency, used elsewhere for cloud-init/wallpaper imports) via a "Browse..." button rather than a fixed-extension picker, since private keys are commonly extensionless (id_ed25519, etc.) — the native file dialog is the closest desktop-appropriate equivalent to path autocomplete. Fix intent picker fields overflowing off-screen in Launch/Deploy dialogs Both dialogs laid the intent dropdown + "new intent name" + "role in intent" fields out in a Row, each a fixed 360px wide — up to 1128px total (dropdown + both text fields, when creating a new intent), well past either dialog's actual width. Row doesn't wrap, so the trailing field(s) were pushed off screen instead of clipped-but-visible, making "role in intent" effectively invisible whenever a new intent was being created (and, to a lesser but still real extent, when picking an existing one too). Switched to Wrap so fields flow onto their own line instead of overflowing. LlmLoad's own intent picker already stacked fields vertically and didn't have this problem; the Intents page's own member-row fields use Expanded rather than fixed widths, so they shrink to fit instead of overflowing. Bump migration ssh/rsync timeouts and add keepalives Migrating an intent with a slow cloud-init (e.g. a minio instance doing a docker pull) was hitting "ssh: connection timed out" — that's ssh's own error, not ours, meaning the TCP connection itself was being dropped while the remote `elp launch`/`elp intent create` sat quiet for a long stretch with no traffic, well before our own wait_for_finished(timeout_ms) would have given up. Likely a NAT/firewall along the path treating an apparently-idle connection as dead. - Added `-o ServerAliveInterval=15 -o ServerAliveCountMax=8` to every ssh/ rsync invocation, so the connection actively stays alive during a long silent remote command instead of looking idle. - Added an explicit `-o ConnectTimeout=30` (only bounds the initial handshake, not the whole command). - Replaced the scattered timeout magic numbers with named constants and raised them: quick ops (staging a file, attaching one mount) 60s -> 2min; provisioning (launch/intent create, which can legitimately take minutes for a slow cloud-init) 10-15min -> 30min; mount data rsync 30min -> 1hr. fix: latest mdns fix --- src/client/gui/lib/catalogue/launch_form.dart | 18 ++-- src/client/gui/lib/grpc_client.dart | 8 +- .../gui/lib/migrate/migrate_screen.dart | 66 +++++++++++--- .../gui/lib/services/service_deploy.dart | 16 ++-- src/daemon/daemon.cpp | 90 ++++++++++++++----- src/daemon/daemon.h | 10 ++- src/platform/mdns_service_linux.cpp | 20 ++++- src/rpc/multipass.proto | 6 ++ 8 files changed, 176 insertions(+), 58 deletions(-) diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 506ad8c781..b1ac23c709 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -310,18 +310,16 @@ class _LaunchFormState extends ConsumerState { final intentSection = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, + // Wrap, not Row: dropdown + new-name + role can add up to more than the drawer's + // width (each is a fixed 360px), which previously pushed the last field(s) off + // screen instead of onto their own line. + Wrap( + spacing: 24, + runSpacing: 12, children: [ intentDropdown, - if (_selectedIntent == _createNewIntentValue) ...[ - const SizedBox(width: 24), - newIntentNameInput, - ], - if (_selectedIntent != null) ...[ - const SizedBox(width: 24), - intentRoleInput, - ], + if (_selectedIntent == _createNewIntentValue) newIntentNameInput, + if (_selectedIntent != null) intentRoleInput, ], ), intentNote, diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index 2f4d3040cb..5acf4f384d 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -437,10 +437,14 @@ class GrpcClient { .then((r) => r?.hosts.toList() ?? const []); } - Future addKnownHost(String label, String target) { + Future addKnownHost( + String label, + String target, { + String identityFile = '', + }) { return doRpc( _client.add_known_host, - AddKnownHostRequest(label: label, target: target), + AddKnownHostRequest(label: label, target: target, identityFile: identityFile), ); } diff --git a/src/client/gui/lib/migrate/migrate_screen.dart b/src/client/gui/lib/migrate/migrate_screen.dart index 5f44917d8c..b0f4f870e9 100644 --- a/src/client/gui/lib/migrate/migrate_screen.dart +++ b/src/client/gui/lib/migrate/migrate_screen.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -141,9 +142,48 @@ class _HostCard extends ConsumerWidget { } } +/// A text field for an ssh identity (private key) file, with a "Browse..." button opening the +/// native file picker (no fixed extension — private keys are commonly extensionless, e.g. +/// id_ed25519) as the closest fit to "autocompletion" for a filesystem path in a Flutter +/// desktop app; the OS's own file dialog already gives path-typing/autocomplete besides. +Widget _identityFileField( + TextEditingController controller, + void Function(void Function()) setDialogState, +) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: controller, + decoration: const InputDecoration( + labelText: 'SSH identity file (optional)', + hintText: '~/.ssh/id_ed25519', + helperText: 'Leave blank to use ssh\'s own default identity.', + ), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 4), + child: IconButton( + tooltip: 'Browse...', + icon: const Icon(Icons.folder_open), + onPressed: () async { + final file = await openFile(); + if (file == null) return; + setDialogState(() => controller.text = file.path); + }, + ), + ), + ], + ); +} + Future showAddHostDialog(BuildContext context, WidgetRef ref) async { final labelController = TextEditingController(); final targetController = TextEditingController(); + final identityController = TextEditingController(); String? error; await showDialog( @@ -175,6 +215,8 @@ Future showAddHostDialog(BuildContext context, WidgetRef ref) async { hintText: 'user@host', ), ), + const SizedBox(height: 12), + _identityFileField(identityController, setDialogState), if (error != null) Padding( padding: const EdgeInsets.only(top: 8), @@ -204,7 +246,11 @@ Future showAddHostDialog(BuildContext context, WidgetRef ref) async { return; } try { - await ref.read(grpcClientProvider).addKnownHost(label, target); + await ref.read(grpcClientProvider).addKnownHost( + label, + target, + identityFile: identityController.text.trim(), + ); ref.invalidate(networkHostsStreamProvider); if (dialogContext.mounted) Navigator.pop(dialogContext); } catch (e) { @@ -220,6 +266,7 @@ Future showAddHostDialog(BuildContext context, WidgetRef ref) async { labelController.dispose(); targetController.dispose(); + identityController.dispose(); } Future showRemoveHostDialog( @@ -296,6 +343,11 @@ Future showMigrateDialog( onChanged: (value) => setDialogState(() { selectedLabel = value; error = null; + // Pre-fill from the newly-selected host's own saved default (still + // editable afterward, e.g. to override for just this migration). + final newlySelected = + value == null ? null : hosts.firstWhereOrNull((h) => h.label == value); + identityController.text = newlySelected?.identityFile ?? ''; }), ), if (selected == null) ...[ @@ -317,17 +369,7 @@ Future showMigrateDialog( ), ], const SizedBox(height: 12), - TextFormField( - controller: identityController, - decoration: const InputDecoration( - labelText: 'SSH identity file (optional)', - hintText: '~/.ssh/id_ed25519', - helperText: - 'elpd runs as root, so ssh otherwise uses root\'s own key, which is ' - 'likely not authorized on the target — point this at your own key ' - 'instead.', - ), - ), + _identityFileField(identityController, setDialogState), const SizedBox(height: 12), CheckboxListTile( controlAffinity: ListTileControlAffinity.leading, diff --git a/src/client/gui/lib/services/service_deploy.dart b/src/client/gui/lib/services/service_deploy.dart index 456e6cfb05..20bc624944 100644 --- a/src/client/gui/lib/services/service_deploy.dart +++ b/src/client/gui/lib/services/service_deploy.dart @@ -140,12 +140,15 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { final intentSection = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, + // Wrap, not Row: dropdown + new-name + role can add up to more than the dialog's + // width (each is a fixed 360px), which previously pushed the last field(s) off + // screen instead of onto their own line. + Wrap( + spacing: 24, + runSpacing: 12, children: [ intentDropdown, - if (_selectedIntent == _createNewIntentValue) ...[ - const SizedBox(width: 24), + if (_selectedIntent == _createNewIntentValue) SpecInput( label: 'New intent name', hint: 'e.g. test-app-1', @@ -153,9 +156,7 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { onSaved: (value) => _newIntentName = value ?? '', width: 360, ), - ], - if (_selectedIntent != null) ...[ - const SizedBox(width: 24), + if (_selectedIntent != null) SpecInput( label: 'Role in intent', helper: 'What this service is within the intent (e.g. "redis").', @@ -164,7 +165,6 @@ class _ServiceDeployDialogState extends ConsumerState<_ServiceDeployDialog> { onSaved: (value) => _intentRole = value ?? '', width: 360, ), - ], ], ), ], diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index 74a062daac..90db7421af 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -411,9 +411,11 @@ std::unordered_map load_intents_db(const mp::Path& return reconstructed_records; } -// Manually-added migration targets ("label" -> "user@host"), the always-available fallback -// to mDNS discovery for hosts on a different subnet/VLAN where multicast doesn't reach. -std::unordered_map load_known_hosts_db(const mp::Path& data_path) +// Manually-added migration targets ("label" -> {target, identity_file}), the always-available +// fallback to mDNS discovery for hosts on a different subnet/VLAN where multicast doesn't reach. +// A plain string value (rather than an object) is accepted too, for records written by the +// short-lived earlier version of this file that only ever stored the target. +std::unordered_map load_known_hosts_db(const mp::Path& data_path) { QDir data_dir{data_path}; QFile db_file{data_dir.filePath(known_hosts_db_name)}; @@ -430,11 +432,22 @@ std::unordered_map load_known_hosts_db(const mp::Path& return {}; } - std::unordered_map reconstructed_records; + std::unordered_map reconstructed_records; for (const auto& [key, record] : records.as_object()) { if (record.is_string()) - reconstructed_records.emplace(key, std::string(record.as_string())); + reconstructed_records.emplace(key, mp::KnownHost{std::string(record.as_string()), {}}); + else if (record.is_object()) + { + const auto& obj = record.as_object(); + std::string target; + if (auto it = obj.find("target"); it != obj.end() && it->value().is_string()) + target = std::string(it->value().as_string()); + std::string identity_file; + if (auto it = obj.find("identity_file"); it != obj.end() && it->value().is_string()) + identity_file = std::string(it->value().as_string()); + reconstructed_records.emplace(key, mp::KnownHost{target, identity_file}); + } } return reconstructed_records; } @@ -3865,6 +3878,14 @@ struct RemoteResult QString error; }; +// Generous on purpose: a cloud-init doing package installs/docker pulls (e.g. a minio image) +// can run quiet for a long time with no output, and a from-scratch `elp launch`/`elp intent +// create` on the target legitimately takes minutes, not seconds. +constexpr int ssh_quick_timeout_ms = 120000; // 2 min: staging a file, attaching one mount +constexpr int ssh_provision_timeout_ms = 1800000; // 30 min: launch / intent create with cloud-init +constexpr int ssh_cleanup_timeout_ms = 60000; // 1 min: best-effort remote temp file cleanup +constexpr int rsync_timeout_ms = 3600000; // 1 hour: syncing a mount's data + // Runs `remote_command` on `target` ("user@host") over ssh, optionally piping `stdin_data` // into it (forwarded transparently through the ssh tunnel to the remote process' own stdin — // e.g. this is how `elp launch --cloud-init -` receives cloud-init content without needing a @@ -3877,7 +3898,22 @@ RemoteResult run_ssh_raw(const std::string& target, int timeout_ms, const std::string& identity_file = {}) { - QStringList args{"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new"}; + // ServerAlive* keeps the connection alive while the remote command runs quiet for a long + // stretch (e.g. a cloud-init doing a slow docker pull) — without it, a NAT/firewall along + // the way can silently drop an apparently-idle TCP connection well before our own + // wait_for_finished(timeout_ms) below would time it out, surfacing as ssh's own "Connection + // timed out"/"Connection reset" rather than ours. ConnectTimeout only bounds the initial + // handshake, not the whole command, so it stays modest. + QStringList args{"-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "ConnectTimeout=30", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=8"}; if (!identity_file.empty()) args << "-i" << QString::fromStdString(identity_file); args << "-T" << QString::fromStdString(target) << QString::fromStdString(remote_command); @@ -3902,7 +3938,7 @@ RemoteResult run_ssh_raw(const std::string& target, RemoteResult run_ssh(const std::string& target, const std::vector& remote_args, const std::string& stdin_data = {}, - int timeout_ms = 120000, + int timeout_ms = ssh_quick_timeout_ms, const std::string& identity_file = {}) { return run_ssh_raw(target, join_shell_command(remote_args), stdin_data, timeout_ms, identity_file); @@ -3916,7 +3952,9 @@ RemoteResult run_rsync(const std::string& source_path, int timeout_ms, const std::string& identity_file = {}) { - auto ssh_command = std::string{"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"}; + auto ssh_command = std::string{"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o " + "ConnectTimeout=30 -o ServerAliveInterval=15 -o " + "ServerAliveCountMax=8"}; if (!identity_file.empty()) ssh_command += " -i " + identity_file; // no spaces expected in a key path; not quoted @@ -3999,7 +4037,8 @@ bool migrate_standalone_vm(const MigrateVmMember& member, outcome.log_lines.push_back( fmt::format("Relaunching \"{}\" on {}...", member.instance_name, target)); - auto result = run_ssh(target, args, member.cloud_init_user_data, 600000, identity_file); + auto result = + run_ssh(target, args, member.cloud_init_user_data, ssh_provision_timeout_ms, identity_file); if (!result.ok) { outcome.message = fmt::format("Failed to launch \"{}\" on {}: {}", @@ -4014,7 +4053,7 @@ bool migrate_standalone_vm(const MigrateVmMember& member, outcome.log_lines.push_back( fmt::format("Syncing mount {} -> {}...", mount.get_source_path(), target)); auto rsync_result = run_rsync( - mount.get_source_path(), target, mount.get_source_path(), 1800000, identity_file); + mount.get_source_path(), target, mount.get_source_path(), rsync_timeout_ms, identity_file); if (!rsync_result.ok) { outcome.message = fmt::format("Failed to sync mount \"{}\": {}", @@ -4027,7 +4066,7 @@ bool migrate_standalone_vm(const MigrateVmMember& member, target, {"elp", "mount", mount.get_source_path(), fmt::format("{}:{}", member.instance_name, target_path)}, {}, - 60000, + ssh_quick_timeout_ms, identity_file); if (!mount_result.ok) { @@ -4072,7 +4111,7 @@ bool migrate_intent(const std::string& intent_name, auto put = run_ssh_raw(target, fmt::format("cat > {}", shell_quote(cloud_init_path)), member.cloud_init_user_data, - 60000, + ssh_quick_timeout_ms, identity_file); if (!put.ok) { @@ -4102,13 +4141,14 @@ bool migrate_intent(const std::string& intent_name, } outcome.log_lines.push_back(fmt::format("Creating intent \"{}\" on {}...", intent_name, target)); - auto result = run_ssh(target, args, {}, 900000, identity_file); + auto result = run_ssh(target, args, {}, ssh_provision_timeout_ms, identity_file); if (!remote_tmp_files.empty()) { std::vector rm_args{"rm", "-f"}; rm_args.insert(rm_args.end(), remote_tmp_files.begin(), remote_tmp_files.end()); - run_ssh(target, rm_args, {}, 30000, identity_file); // best-effort cleanup, failure not fatal + // best-effort cleanup, failure not fatal + run_ssh(target, rm_args, {}, ssh_cleanup_timeout_ms, identity_file); } if (!result.ok) @@ -4126,7 +4166,7 @@ bool migrate_intent(const std::string& intent_name, outcome.log_lines.push_back( fmt::format("Syncing mount {} -> {}...", mount.get_source_path(), target)); auto rsync_result = run_rsync( - mount.get_source_path(), target, mount.get_source_path(), 1800000, identity_file); + mount.get_source_path(), target, mount.get_source_path(), rsync_timeout_ms, identity_file); if (!rsync_result.ok) { outcome.message = fmt::format("Failed to sync mount \"{}\": {}", @@ -4139,7 +4179,7 @@ bool migrate_intent(const std::string& intent_name, target, {"elp", "mount", mount.get_source_path(), fmt::format("{}:{}", remote_instance, target_path)}, {}, - 60000, + ssh_quick_timeout_ms, identity_file); if (!mount_result.ok) { @@ -4377,19 +4417,23 @@ try host->set_discovered(true); // If this discovered peer's label matches a known host, surface the saved "user@host" - // for it too, so the GUI can pre-fill a target instead of asking for a username again. + // (and identity file) for it too, so the GUI can pre-fill instead of asking again. if (auto it = known_hosts.find(label); it != known_hosts.end()) - host->set_target(it->second); + { + host->set_target(it->second.target); + host->set_identity_file(it->second.identity_file); + } } - for (const auto& [label, target] : known_hosts) + for (const auto& [label, host_entry] : known_hosts) { if (discovered_hosts.count(label)) continue; // already listed above, with the richer discovered info auto* host = response.add_hosts(); host->set_label(label); - host->set_target(target); + host->set_target(host_entry.target); + host->set_identity_file(host_entry.identity_file); host->set_discovered(false); } @@ -4417,7 +4461,7 @@ try return context->set_value( {grpc::StatusCode::INVALID_ARGUMENT, "Target must be in \"user@host\" form", ""}); - known_hosts[label] = target; + known_hosts[label] = {target, request->identity_file()}; persist_known_hosts(); AddKnownHostReply reply; @@ -4766,8 +4810,8 @@ void mp::Daemon::persist_intents() void mp::Daemon::persist_known_hosts() { boost::json::object records; - for (const auto& [label, target] : known_hosts) - records[label] = target; + for (const auto& [label, host] : known_hosts) + records[label] = {{"target", host.target}, {"identity_file", host.identity_file}}; QDir data_dir{mp::utils::backend_directory_path(config->data_directory, config->factory->get_backend_directory_name())}; diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index bf319dd317..4dca5a6507 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -48,6 +48,14 @@ struct DaemonConfig; struct DaemonRpcContext; class SettingsHandler; +// A manually-added migration target (see MigrateRequest/NetworkHost) — the always-available +// fallback to mDNS discovery, for hosts on a different subnet/VLAN or when avahi isn't running. +struct KnownHost +{ + std::string target; // "user@host" + std::string identity_file; // optional default ssh/rsync identity for this host +}; + class Daemon : public QObject, public multipass::VMStatusMonitor { Q_OBJECT @@ -360,7 +368,7 @@ public slots: std::unordered_map vm_instance_specs; std::unordered_map intents; InstanceTable operative_instances; - std::unordered_map known_hosts; // label -> "user@host" + std::unordered_map known_hosts; // label -> host std::unordered_map discovered_hosts; // label -> info bool is_bridged(const std::string& instance_name) const; diff --git a/src/platform/mdns_service_linux.cpp b/src/platform/mdns_service_linux.cpp index 422d9e0b9e..4a4d6818ea 100644 --- a/src/platform/mdns_service_linux.cpp +++ b/src/platform/mdns_service_linux.cpp @@ -27,6 +27,7 @@ #include #include +#include #include namespace mp = multipass; @@ -240,7 +241,7 @@ class AvahiMdnsService : public mp::MdnsService } static void resolve_callback(AvahiServiceResolver* r, - AvahiIfIndex, + AvahiIfIndex interface, AvahiProtocol, AvahiResolverEvent event, const char* name, @@ -260,9 +261,24 @@ class AvahiMdnsService : public mp::MdnsService char address_str[AVAHI_ADDRESS_STR_MAX]; avahi_address_snprint(address_str, sizeof(address_str), address); + std::string resolved_address = address_str; + // An IPv6 link-local address (fe80::/10) is only routable with an interface + // zone id attached (e.g. "fe80::1%eth0") — without it, ssh/getaddrinfo silently + // hang/timeout trying to reach it. mDNS replies commonly come back link-local, + // so this isn't an edge case: it's the common failure mode on real LANs. + if (address->proto == AVAHI_PROTO_INET6 && + address->data.ipv6.address[0] == 0xfe && + (address->data.ipv6.address[1] & 0xc0) == 0x80 && + interface != AVAHI_IF_UNSPEC) + { + char ifname[IF_NAMESIZE]; + if (if_indextoname(static_cast(interface), ifname)) + resolved_address += fmt::format("%{}", ifname); + } + mp::MdnsHostInfo info; info.label = name ? name : ""; - info.address = address_str; + info.address = resolved_address; info.host_name = txt_value(txt, "host_name"); if (info.host_name.empty()) info.host_name = host_name ? host_name : ""; diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index 20ba31da27..d08fcd7466 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -746,6 +746,11 @@ message NetworkHost { string backend = 6; string address = 7; // resolved IP/hostname (discovered hosts only) bool discovered = 8; // true: found via mDNS; false: manually added via add_known_host + // Default ssh/rsync identity file for this host (see MigrateRequest.identity_file); + // empty means "use ssh's own default". Set on a known host via add_known_host; a + // discovered host inherits it too once its label matches a known one (see + // list_network_hosts, same as it already does for `target`). + string identity_file = 9; } message ListNetworkHostsRequest { @@ -761,6 +766,7 @@ message AddKnownHostRequest { string label = 1; string target = 2; // "user@host" int32 verbosity_level = 3; + string identity_file = 4; // optional default ssh/rsync identity for this host } message AddKnownHostReply {