diff --git a/BUILD.linux.md b/BUILD.linux.md index 3ede6c1192..011469cc56 100644 --- a/BUILD.linux.md +++ b/BUILD.linux.md @@ -1,6 +1,57 @@ # 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, 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`. + +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/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..439c03b4c0 --- /dev/null +++ b/include/multipass/intent_spec.h @@ -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 . + * + */ + +#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 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; + }; + + 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/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 new file mode 100644 index 0000000000..c7ba5bbe4a --- /dev/null +++ b/packaging/archlinux/PKGBUILD.in @@ -0,0 +1,163 @@ +# 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=('GPL-3.0-only') +depends=( + 'qt6-base' + 'gcc-libs' + 'glibc' + 'libpng' + 'libxml2' + 'dnsmasq' + 'slang' + 'iproute2' + 'iptables-nft' + 'iputils' + 'xterm' + 'mesa' + 'openssl' + 'libnotify' + 'libayatana-appindicator' + 'gtkmm3' + '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' + 'cmake' + 'ninja' + 'pkgconf' + 'rust' # rxx/ Rust component built via cargo (BUILD.linux.md's rustup step) + 'clang' + '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' + 'unzip' + 'python' + 'python-distlib' # qemu's mkvenv needs distlib; pip-installing it hits Arch's PEP 668 lock + '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 + # 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' +) +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/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" "elp-api.service") +sha256sums=('SKIP' 'SKIP' 'SKIP') + +pkgver() { + cd "${srcdir}/elp" + 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. + # 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. 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 v1.0.0-dev -m "local build" +} + +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" + + # 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-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" \ + "${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/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/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..9ebd004db1 --- /dev/null +++ b/scripts/build-arch.sh @@ -0,0 +1,85 @@ +#!/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" +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 +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}" +# 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" +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}" +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 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 diff --git a/src/client/cli/client.cpp b/src/client/cli/client.cpp index edf65b328c..63d1e859b6 100644 --- a/src/client/cli/client.cpp +++ b/src/client/cli/client.cpp @@ -28,9 +28,11 @@ #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" +#include "cmd/migrate.h" #include "cmd/mount.h" #include "cmd/networks.h" #include "cmd/prefer.h" @@ -119,6 +121,8 @@ mp::Client::Client(ClientConfig& config) add_command(); 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..2ea723abf9 100644 --- a/src/client/cli/cmd/CMakeLists.txt +++ b/src/client/cli/cmd/CMakeLists.txt @@ -28,9 +28,11 @@ add_library(commands STATIC get.cpp help.cpp info.cpp + intent.cpp launch.cpp list.cpp llm.cpp + migrate.cpp mount.cpp networks.cpp prefer.cpp diff --git a/src/client/cli/cmd/intent.cpp b/src/client/cli/cmd/intent.cpp new file mode 100644 index 0000000000..4508a3b0cf --- /dev/null +++ b/src/client/cli/cmd/intent.cpp @@ -0,0 +1,348 @@ +/* + * 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 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) +{ + 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 | add | list | info | delete", ""); + 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); + if (status != ParseCode::Ok) + return parser->returnCodeFrom(status); + + const auto args = parser->positionalArguments(); + if (args.empty()) + { + 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") + return run_info(parser); + if (action == "delete") + return run_delete(parser); + + cerr << fmt::format( + "Unknown action \"{}\"; expected create, add, 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 add to, list, inspect, or delete such " + "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" + " elp intent delete [--purge]"); +} + +bool cmd::Intent::parse_members(ArgParser* parser, + google::protobuf::RepeatedPtrField* members) +{ + for (const auto& role : parser->values(service_option)) + { + auto* member = members->Add(); + member->set_role(role.toStdString()); + } + + for (const auto& spec : parser->values(instance_option)) + { + const auto fields = spec.split(':'); + if (fields[0].isEmpty()) + { + cerr << fmt::format("Invalid --instance spec \"{}\"; expected " + "role:image:cloud-init-file:cores:mem:disk.\n", + spec.toStdString()); + return false; + } + + auto* member = members->Add(); + 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 false; + } + 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()); + } + + 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; +} + +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); + } + // --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()); + 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(); + 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_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) && + !parser->isSet(model_option)) + { + cerr << "Please specify at least one member with --service, --instance, or --model.\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; + 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(), + member.kind().empty() ? "vm" : member.kind(), + 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(), + member.kind().empty() ? "vm" : member.kind(), + 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..ac2f6b97b6 --- /dev/null +++ b/src/client/cli/cmd/intent.h @@ -0,0 +1,46 @@ +/* + * 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_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/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/cli/cmd/migrate.cpp b/src/client/cli/cmd/migrate.cpp new file mode 100644 index 0000000000..5adfe38f4e --- /dev/null +++ b/src/client/cli/cmd/migrate.cpp @@ -0,0 +1,134 @@ +/* + * 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."}; +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) +{ + 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_identity_file(identity_file.toStdString()); + 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\n" + " elp migrate my-instance --to user@host --identity ~/.ssh/id_ed25519"); +} + +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) + 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); + 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 new file mode 100644 index 0000000000..413c858a38 --- /dev/null +++ b/src/client/cli/cmd/migrate.h @@ -0,0 +1,42 @@ +/* + * 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; + QString identity_file; + bool copy{false}; +}; +} // namespace multipass::cmd diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 724985014c..b1ac23c709 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'; @@ -81,6 +82,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; @@ -130,6 +136,11 @@ class _LaunchFormState extends ConsumerState { var addingMount = false; final scrollController = ScrollController(); final cloudInitSectionKey = GlobalKey(); + String? _selectedIntent; + String _newIntentName = ''; + String _intentRole = ''; + bool _addingToIntent = false; + String? _intentError; String? _cloudInitError; @override @@ -178,6 +189,7 @@ class _LaunchFormState extends ConsumerState { loading: () => null, error: (_, __) => null, ); + final intentNames = ref.watch(intentNamesProvider); final closeButton = IconButton( icon: const Icon(Icons.close), @@ -249,6 +261,79 @@ 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)', + _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").', + 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 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: [ + // 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) newIntentNameInput, + if (_selectedIntent != null) 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 +445,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 +530,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 +679,27 @@ 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 bool started; + final String successSidebarKey; + if (selectedIntent == null) { + started = await initiateLaunchFlow( + context, + ref, + launchRequest.deepCopy(), + mountRequests: mountRequests.map((r) => r.deepCopy()).toList(), + os: imageInfo.os, + ); + successSidebarKey = elpVm(launchRequest.instanceName).sidebarKey; + } else { + // 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; @@ -596,9 +707,90 @@ 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); + } + } + + /// 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 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; + } + + 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 grpcClient = ref.read(grpcClientProvider); + final role = _intentRole.trim(); + final String intentName; + final Future op; + if (newIntentName != null) { + intentName = newIntentName; + op = grpcClient.intentCreate( + IntentCreateRequest(name: intentName, members: [member]), + ); + } else { + intentName = existingIntentName!; + op = grpcClient.intentAddMember( + IntentAddMemberRequest(name: intentName, members: [member]), + ); + } + + // 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; + setState(() => _intentError = '$error'); + return false; + } finally { + if (mounted) setState(() => _addingToIntent = false); } } } diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index cb51f74135..5acf4f384d 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -400,6 +400,58 @@ 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 migrate( + String name, + String target, { + bool copy = false, + String identityFile = '', + }) { + return doRpc( + _client.migrate, + MigrateRequest(name: name, target: target, copy: copy, identityFile: identityFile), + ); + } + + Future> listNetworkHosts() { + return doRpc(_client.list_network_hosts, ListNetworkHostsRequest(), log: false) + .then((r) => r?.hosts.toList() ?? const []); + } + + Future addKnownHost( + String label, + String target, { + String identityFile = '', + }) { + return doRpc( + _client.add_known_host, + AddKnownHostRequest(label: label, target: target, identityFile: identityFile), + ); + } + + 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 new file mode 100644 index 0000000000..0cb6d02325 --- /dev/null +++ b/src/client/gui/lib/intents/intents_screen.dart @@ -0,0 +1,465 @@ +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 '../migrate/migrate_screen.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; + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + body: PageSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Intents', + 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'), + ), + ], + ), + 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: 'Migrate', + icon: const Icon(Icons.moving), + onPressed: () => showMigrateDialog(context, ref, name: 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) { + final isLlm = member.kind == 'llm'; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: () => ref.read(sidebarKeyProvider.notifier).set( + isLlm + ? LlmInstanceId( + instanceId: member.instanceName, + modelId: '', + ).sidebarKey + : 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( + isLlm ? '(LLM: ${member.instanceName})' : '(${member.instanceName})', + style: TextStyle(color: onSurface.withValues(alpha: 0.6)), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ); + } +} + +class _MemberFields { + _MemberFields() + : roleController = 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(), + ); + } +} + +Widget _memberFieldsRow( + _MemberFields fields, { + VoidCallback? onRemove, +}) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: TextFormField( + controller: fields.modelIdController, + decoration: const InputDecoration( + labelText: 'Model ID (optional, for an LLM member)', + hintText: 'e.g. llama-3.1-8b-instruct; ignores Image above when set', + ), + ), + ), + ], + ), + ); +} + +Future showCreateIntentDialog(BuildContext context, WidgetRef ref) async { + final nameController = TextEditingController(); + // 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( + 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 (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: () => setDialogState(() => members.remove(member)), + ), + TextButton.icon( + onPressed: () => + setDialogState(() => members.add(_MemberFields())), + icon: const Icon(Icons.add, size: 18), + label: const Text('Add a 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/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/main.dart b/src/client/gui/lib/main.dart index 30fee8da2f..a1aeab882f 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -21,6 +21,8 @@ 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 'migrate/migrate_screen.dart'; import 'layout/compact_layout.dart'; import 'logger.dart'; import 'llm/catalogue/llm_catalogue_screen.dart'; @@ -272,6 +274,8 @@ 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..b0f4f870e9 --- /dev/null +++ b/src/client/gui/lib/migrate/migrate_screen.dart @@ -0,0 +1,449 @@ +import 'package:collection/collection.dart'; +import 'package:file_selector/file_selector.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), + ), + ], + ], + ), + ), + ); + } +} + +/// 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( + 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', + ), + ), + const SizedBox(height: 12), + _identityFileField(identityController, setDialogState), + 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, + identityFile: identityController.text.trim(), + ); + 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(); + identityController.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(); + final identityController = 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; + // 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) ...[ + 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), + _identityFileField(identityController, setDialogState), + 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, + identityFile: identityController.text.trim(), + ); + 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(); + identityController.dispose(); +} diff --git a/src/client/gui/lib/providers.dart b/src/client/gui/lib/providers.dart index eec7b1b9a5..226e24aa18 100644 --- a/src/client/gui/lib/providers.dart +++ b/src/client/gui/lib/providers.dart @@ -299,6 +299,55 @@ 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(); +}); + +/// 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/services/service_deploy.dart b/src/client/gui/lib/services/service_deploy.dart index bff5bdc539..20bc624944 100644 --- a/src/client/gui/lib/services/service_deploy.dart +++ b/src/client/gui/lib/services/service_deploy.dart @@ -7,9 +7,12 @@ 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 '../intents/intents_screen.dart'; import '../l10n/app_localizations.dart'; import '../layout/compact_layout.dart'; +import '../notifications.dart'; import '../providers.dart'; import '../sidebar.dart'; import '../widgets/launchpad_button.dart'; @@ -44,6 +47,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 +79,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 +118,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: [ + // 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) + SpecInput( + label: 'New intent name', + hint: 'e.g. test-app-1', + initialValue: _newIntentName, + onSaved: (value) => _newIntentName = value ?? '', + width: 360, + ), + if (_selectedIntent != null) + 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 +235,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,19 +304,108 @@ 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; + final String successSidebarKey; + 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, + ); + successSidebarKey = destination; + } else { + // 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 + /// (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 role = _intentRole.trim(); + final String intentName; + final Future op; + if (newIntentName != null) { + intentName = newIntentName; + op = grpcClient.intentCreate( + IntentCreateRequest(name: intentName, members: [member]), + ); + } else { + intentName = existingIntentName!; + op = grpcClient.intentAddMember( + IntentAddMemberRequest(name: intentName, members: [member]), + ); + } + + 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; + setState(() => _error = '$error'); + return false; + } } } diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index f9783d653f..bd059d3853 100644 --- a/src/client/gui/lib/sidebar.dart +++ b/src/client/gui/lib/sidebar.dart @@ -20,6 +20,8 @@ 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 'migrate/migrate_screen.dart'; import 'l10n/app_localizations.dart'; import 'layout/compact_layout.dart'; import 'llm/catalogue/llm_catalogue_screen.dart'; @@ -300,6 +302,24 @@ class SideBar extends ConsumerWidget { }, ); + final intents = SidebarEntry( + icon: FontAwesomeIcons.objectGroup, + selected: isSelected(IntentsScreen.sidebarKey), + label: 'Intents', + onPressed: () { + ref.read(sidebarKeyNotifier).set(IntentsScreen.sidebarKey); + }, + ); + + 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) || @@ -559,6 +579,7 @@ class SideBar extends ConsumerWidget { SidebarSectionHeader(l10n.sidebarSectionCompute), catalogue, instances, + intents, cloudInit, SidebarSectionHeader( l10n.sidebarSectionAi, @@ -577,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/client/gui/lib/vm_table/vms.dart b/src/client/gui/lib/vm_table/vms.dart index f2cff56748..3cfbe5b897 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); @@ -63,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 { @@ -86,6 +122,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 +148,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 +171,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,15 +222,122 @@ 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), + // "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, ), ), - ), + const SizedBox(height: 16), + ], ], ); } } + +/// 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/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() 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..90db7421af 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 @@ -46,6 +48,8 @@ #include #include #include +#include +#include #include #include #include @@ -70,6 +74,7 @@ #include +#include #include #include #include @@ -167,6 +172,8 @@ 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."; @@ -372,6 +379,79 @@ 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; +} + +// 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)}; + 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, 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; +} + std::string generate_next_clone_name(int clone_count, const std::string& source_name) { return fmt::format("{}-clone{}", source_name, clone_count + 1); @@ -443,6 +523,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, @@ -621,6 +708,14 @@ 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_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); 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); @@ -642,6 +737,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) @@ -1425,6 +1530,31 @@ 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())); + 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; try @@ -3081,6 +3211,1295 @@ catch (const std::exception& e) context->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what())); } +namespace +{ +// 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) + : 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()) + { + OuterReply forwarded; + forwarded.set_log_line(reply.log_line()); + outer->Write(forwarded); + } + return true; + } + +private: + 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, +// 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); +} + +// 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) +{ + 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()) + { + 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 = mp::find_intent_service_template(role); + if (!tmpl) + { + 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; + } + + 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(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)); + } + 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( + 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, + launch_requests, + member_sink, + launched, + member_index, + launch_next, + on_all_launched, + on_failure] { + if (*member_index >= launch_requests->size()) + 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, 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 on_failure(status); + + launched->push_back( + {role, (*launch_requests)[*member_index].instance_name(), "vm"}); + ++(*member_index); + (*launch_next)(); + }); + + try + { + 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 + on_failure(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); + } + }; + + (*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, + 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), + ""}); + // 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); + 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, 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); }); +} +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 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, 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); }); +} +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, + 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); + proto_member->set_kind(member.kind); + proto_member->mutable_instance_status()->set_status(intent_member_status(member)); + } + } + + 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); + proto_member->set_kind(member.kind); + proto_member->mutable_instance_status()->set_status(intent_member_status(member)); + } + + 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; + 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; + + DeleteReply throwaway_response; + instances_dirty |= delete_vm(vm_it, purge, throwaway_response); + } + + if (instances_dirty) + persist_instances(); + if (llm_dispatcher) + llm_dispatcher->unload_instances_blocking(llm_instance_ids); + + 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(), "")); +} + +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; +}; + +// 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 +// 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 std::string& identity_file = {}) +{ + // 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); + 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 = 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); +} + +// 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 std::string& identity_file = {}) +{ + 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 + + const QStringList args{ + "-az", + "-e", + QString::fromStdString(ssh_command), + "--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, + const std::string& identity_file, + 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, ssh_provision_timeout_ms, identity_file); + 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(), rsync_timeout_ms, identity_file); + 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)}, + {}, + ssh_quick_timeout_ms, + identity_file); + 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, + const std::string& identity_file, + 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, + ssh_quick_timeout_ms, + identity_file); + 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, {}, 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()); + // best-effort cleanup, failure not fatal + run_ssh(target, rm_args, {}, ssh_cleanup_timeout_ms, identity_file); + } + + 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(), rsync_timeout_ms, identity_file); + 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)}, + {}, + ssh_quick_timeout_ms, + identity_file); + 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, + const std::string& identity_file, + 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, identity_file, outcome)) + return outcome; + } + else + { + assert(vm_members.size() == 1 && llm_members.empty()); + if (!migrate_standalone_vm(vm_members.front(), target, identity_file, 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& identity_file = request->identity_file(); + 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, 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(); + 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" + // (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.target); + host->set_identity_file(it->second.identity_file); + } + } + + 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(host_entry.target); + host->set_identity_file(host_entry.identity_file); + 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, request->identity_file()}; + 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, @@ -3128,6 +4547,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()) { @@ -3378,6 +4798,27 @@ 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::persist_known_hosts() +{ + boost::json::object records; + 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())}; + 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); @@ -3455,7 +4896,19 @@ 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()}, + 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 @@ -3465,6 +4918,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, @@ -3480,6 +4938,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, @@ -4189,6 +5650,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); diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index 2a95db0cf7..4dca5a6507 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -23,6 +23,8 @@ #include #include #include +#include +#include #include #include #include @@ -46,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 @@ -54,6 +64,8 @@ class Daemon : public QObject, public multipass::VMStatusMonitor ~Daemon(); void persist_instances(); + void persist_intents(); + void persist_known_hosts(); protected: using InstanceTable = std::unordered_map; @@ -157,6 +169,31 @@ public slots: grpc::ServerReaderWriterInterface* server, DaemonRpcContext* context); + virtual void intent_create( + const IntentCreateRequest* request, + 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, + 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); @@ -194,12 +231,59 @@ 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, 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); + + // 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); @@ -278,10 +362,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 -> 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 ac1cd038d3..7239746a53 100644 --- a/src/daemon/daemon_rpc.cpp +++ b/src/daemon/daemon_rpc.cpp @@ -252,6 +252,71 @@ 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_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) +{ + 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) @@ -543,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 e61f905984..ccff93e52c 100644 --- a/src/daemon/daemon_rpc.h +++ b/src/daemon/daemon_rpc.h @@ -83,6 +83,22 @@ 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_add_member( + const IntentAddMemberRequest* 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); @@ -146,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); @@ -229,6 +260,21 @@ 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_add_member( + 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; @@ -281,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/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/daemon/llm_dispatcher.cpp b/src/daemon/llm_dispatcher.cpp index 86fb4cdb83..05b4819341 100644 --- a/src/daemon/llm_dispatcher.cpp +++ b/src/daemon/llm_dispatcher.cpp @@ -83,6 +83,17 @@ 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); +} + +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 d6b740c0d5..6263500ad8 100644 --- a/src/daemon/llm_dispatcher.h +++ b/src/daemon/llm_dispatcher.h @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -48,6 +49,10 @@ class LlmDispatcher : public QObject ~LlmDispatcher() override; void unload_instances_blocking(const std::vector& instance_ids); + // 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 d807fe1ede..516f15eac6 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,35 @@ 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::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}; @@ -1404,6 +1448,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..fcc7310914 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,10 @@ 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; + // 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..db306231b5 100644 --- a/src/platform/CMakeLists.txt +++ b/src/platform/CMakeLists.txt @@ -13,17 +13,41 @@ # 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) + + # 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) + platform_unix.cpp + mdns_service.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: + # 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 + ${MDNS_SERVICE_HEADER}) qt6_disable_unicode_defines(${TARGET_NAME}) target_link_libraries(${TARGET_NAME} jsoncpp_static @@ -36,11 +60,20 @@ 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 + ${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}) 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..4a4d6818ea --- /dev/null +++ b/src/platform/mdns_service_linux.cpp @@ -0,0 +1,336 @@ +/* + * 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 +#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 interface, + 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); + + 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 = resolved_address; + 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 ae90b3e026..d08fcd7466 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -44,12 +44,21 @@ 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_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); rpc daemon_info (stream DaemonInfoRequest) returns (stream DaemonInfoReply); rpc wait_ready (stream WaitReadyRequest) returns (stream WaitReadyReply); rpc zones (stream ZonesRequest) returns (stream ZonesReply); 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); @@ -96,6 +105,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 +285,8 @@ message DetailedInfoItem { Zone zone = 9; string service_id = 10; + string intent = 11; + string intent_role = 12; } message InfoReply { @@ -566,6 +579,104 @@ 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; + // 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; + // 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 { + string name = 1; + repeated IntentMemberRequest members = 2; + int32 verbosity_level = 3; +} + +message IntentCreateReply { + string log_line = 1; + 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; + 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 { + 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; } @@ -593,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 { @@ -602,6 +716,74 @@ 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; + // 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 { + 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 + // 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 { + 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; + string identity_file = 4; // optional default ssh/rsync identity for this host +} + +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; } @@ -754,6 +936,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 { @@ -793,6 +979,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/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 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..e1fbb0fffa --- /dev/null +++ b/src/utils/intent_spec.cpp @@ -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 . + * + */ + +#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}, + {"kind", member.kind}, + }); + + 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")), + lookup_or(member, "kind", "vm")}); + + return spec; +} 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", {}), }; }