From b026e1455a33af552eabbba6708368c947a159a2 Mon Sep 17 00:00:00 2001 From: haolianglh Date: Mon, 27 Jul 2026 14:21:49 +0800 Subject: [PATCH 1/8] Add ublk-based block device frontend The existing TCMU frontend requires a single resident daemon for all devices on a host: netlink only notifies the registered handler process, so one crash takes down every overlaybd disk, and the code carries several netlink workarounds as the price of that architecture. Introduce overlaybd-ublk, an alternative frontend based on the kernel's io_uring-backed ublk driver, with no SCSI overhead and no single point of failure: one process serves exactly one device, so device create/delete collapses into process start/stop and failures are isolated to a single disk. * add/del/list CLI following ublk ecosystem conventions; add returns only after the device is usable and prints the device path * per-queue event loop driven by photon: ring fd polled via epoll, CQEs reaped with the non-blocking libublksrv API, IO served by coroutines calling ImageFile directly * IO mapping mirrors the TCMU frontend (READ/WRITE/FLUSH/DISCARD); writable devices honestly advertise a volatile cache, read-only devices do not * works on kernels with partial io_uring feature sets: queue setup retries without IORING_SETUP_COOP_TASKRUN, device deletion prefers DEL_DEV_ASYNC and falls back to a bounded sync del * libublksrv v1.7 and liburing 2.8 are fetched at configure time and statically linked (dual MIT/LGPL, used under MIT); building needs autotools, opt out with -DBUILD_UBLK_FRONTEND=off * verified end-to-end on ublk-capable kernels: startup sequence, idle wakeup, full-queue fio pressure with crc32c verify, FLUSH path, add/del lifecycle, and read-only semantics; 13 unit tests cover the IO mapping and CLI layers Signed-off-by: haolianglh --- .github/workflows/release/build.sh | 6 +- CMake/Findliburing.cmake | 72 +++++ CMake/Findublksrv.cmake | 78 +++++ CMakeLists.txt | 7 + README.md | 36 +++ src/CMakeLists.txt | 3 + src/ublk/CMakeLists.txt | 35 ++ src/ublk/cli.cpp | 63 ++++ src/ublk/cli.h | 34 ++ src/ublk/io_dispatch.cpp | 57 ++++ src/ublk/io_dispatch.h | 41 +++ src/ublk/main.cpp | 193 +++++++++++ src/ublk/test/CMakeLists.txt | 21 ++ src/ublk/test/ublk_dispatch_test.cpp | 225 +++++++++++++ src/ublk/ublk_device.cpp | 457 +++++++++++++++++++++++++++ src/ublk/ublk_device.h | 46 +++ 16 files changed, 1372 insertions(+), 2 deletions(-) create mode 100644 CMake/Findliburing.cmake create mode 100644 CMake/Findublksrv.cmake create mode 100644 src/ublk/CMakeLists.txt create mode 100644 src/ublk/cli.cpp create mode 100644 src/ublk/cli.h create mode 100644 src/ublk/io_dispatch.cpp create mode 100644 src/ublk/io_dispatch.h create mode 100644 src/ublk/main.cpp create mode 100644 src/ublk/test/CMakeLists.txt create mode 100644 src/ublk/test/ublk_dispatch_test.cpp create mode 100644 src/ublk/ublk_device.cpp create mode 100644 src/ublk/ublk_device.h diff --git a/.github/workflows/release/build.sh b/.github/workflows/release/build.sh index 0e39dc25..016a0053 100644 --- a/.github/workflows/release/build.sh +++ b/.github/workflows/release/build.sh @@ -73,11 +73,13 @@ elif [[ ${OS} =~ "centos" ]]; then fi yum install -y epel-release libaio-devel libcurl-devel openssl-devel libnl3-devel e2fsprogs-devel - yum install -y rpm-build make git wget sudo autoconf automake libtool + # pkgconfig: ublksrv's configure uses PKG_CHECK_MODULES (needs pkg.m4 at autoreconf time) + yum install -y rpm-build make git wget sudo autoconf automake libtool pkgconfig yum install --skip-broken -y libzstd-static gcc gcc-c++ binutils libzstd-devel elif [[ ${OS} =~ "mariner" ]]; then yum install -y libaio-devel libcurl-devel openssl-devel libnl3-devel e2fsprogs-devel glibc-devel libzstd-devel binutils ca-certificates-microsoft build-essential - yum install -y rpm-build make git wget sudo tar gcc gcc-c++ autoconf automake libtool + # pkg-config: ublksrv's configure uses PKG_CHECK_MODULES (needs pkg.m4 at autoreconf time) + yum install -y rpm-build make git wget sudo tar gcc gcc-c++ autoconf automake libtool pkg-config DISTRO=${OS/:/.} PACKAGE_RELEASE="-DPACKAGE_RELEASE=${RELEASE_NO}.${DISTRO}" diff --git a/CMake/Findliburing.cmake b/CMake/Findliburing.cmake new file mode 100644 index 00000000..b7498840 --- /dev/null +++ b/CMake/Findliburing.cmake @@ -0,0 +1,72 @@ +# liburing (hard dependency of ublksrv). +# Distro liburing packages are commonly missing or too old (>= 2.2 required) +# and behavior differs across versions, so like every other dependency of +# this project it is always fetched at a pinned version and statically +# linked -- never taken from the system. +# FetchContent only downloads; the build uses liburing's own configure/make. +include(FetchContent) +set(FETCHCONTENT_QUIET false) + +# CMake 3.30+ deprecates single-argument FetchContent_Populate (CMP0169); +# explicitly keep the old behavior +if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) +endif() + +FetchContent_Declare( + liburing + GIT_REPOSITORY https://github.com/axboe/liburing.git + GIT_TAG liburing-2.8 +) + +# download only, no add_subdirectory (liburing is a plain Makefile project) +FetchContent_GetProperties(liburing) +if(NOT liburing_POPULATED) + FetchContent_Populate(liburing) +endif() + +if(NOT TARGET liburing_build) + # OUTPUT points at the final artifact => incremental build, skipped once present + add_custom_command( + OUTPUT ${liburing_SOURCE_DIR}/src/liburing.a + WORKING_DIRECTORY ${liburing_SOURCE_DIR} + COMMAND ./configure + COMMAND make -C src -j + COMMENT "Building liburing with its own configure/make" + VERBATIM + ) + add_custom_target(liburing_build DEPENDS ${liburing_SOURCE_DIR}/src/liburing.a) +endif() + +set(LIBURING_LIBRARIES ${liburing_SOURCE_DIR}/src/liburing.a) +set(LIBURING_INCLUDE_DIRS ${liburing_SOURCE_DIR}/src/include) + +# Generate an in-build-tree liburing.pc for ublksrv's configure check +# PKG_CHECK_MODULES([LIBURING], [liburing >= 2.2]) (the official +# build_with_liburing_src flow relies on pkg-config metadata) +set(LIBURING_PC_DIR ${CMAKE_BINARY_DIR}/liburing-pkgconfig) +file(WRITE ${LIBURING_PC_DIR}/liburing.pc +"prefix=${liburing_SOURCE_DIR} +libdir=${liburing_SOURCE_DIR}/src +includedir=${liburing_SOURCE_DIR}/src/include + +Name: liburing +Version: 2.8 +Description: io_uring library +Libs: -L\${libdir} -luring +Cflags: -I\${includedir} +") + +if(NOT TARGET liburing_static) + add_library(liburing_static STATIC IMPORTED GLOBAL) + set_target_properties(liburing_static PROPERTIES + IMPORTED_LOCATION ${LIBURING_LIBRARIES} + INTERFACE_INCLUDE_DIRECTORIES ${LIBURING_INCLUDE_DIRS} + ) +endif() +add_dependencies(liburing_static liburing_build) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(liburing DEFAULT_MSG LIBURING_LIBRARIES LIBURING_INCLUDE_DIRS) + +mark_as_advanced(LIBURING_LIBRARIES LIBURING_INCLUDE_DIRS) diff --git a/CMake/Findublksrv.cmake b/CMake/Findublksrv.cmake new file mode 100644 index 00000000..d8a4ae31 --- /dev/null +++ b/CMake/Findublksrv.cmake @@ -0,0 +1,78 @@ +# ublksrv (ublk userspace library; autotools project, no CMake). +# Only the lib/ directory (pure-C libublksrv) is built: +# - avoids the C++20/-fcoroutines requirement of the ublk CLI tool; +# - libnfs/libiscsi/gnutls only serve its bundled targets, all disabled. +# liburing is built from source by Findliburing.cmake and fed to configure +# via pkg-config/CFLAGS/LDFLAGS (the official build_with_liburing_src flow). +# License: lib/ + include/ublksrv.h are dual MIT/LGPL; linked under MIT. +include(FetchContent) +set(FETCHCONTENT_QUIET false) + +if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) +endif() + +# the autotools build needs extra host tools; fail early with a clear message +find_program(AUTORECONF_EXECUTABLE autoreconf) +find_program(LIBTOOLIZE_EXECUTABLE libtoolize) +find_program(AUTOMAKE_EXECUTABLE automake) +if(NOT AUTORECONF_EXECUTABLE OR NOT LIBTOOLIZE_EXECUTABLE OR NOT AUTOMAKE_EXECUTABLE) + message(FATAL_ERROR + "BUILD_UBLK_FRONTEND requires autotools (autoconf/automake/libtool) " + "to build ublksrv. Install them or configure with -DBUILD_UBLK_FRONTEND=off") +endif() + +FetchContent_Declare( + ublksrv + GIT_REPOSITORY https://github.com/ublk-org/ublksrv.git + GIT_TAG f6c643952d1cdc7f6460630638fe6b5454ca1c4d # v1.7 +) + +FetchContent_GetProperties(ublksrv) +if(NOT ublksrv_POPULATED) + FetchContent_Populate(ublksrv) +endif() + +if(NOT TARGET libublksrv_build) + add_custom_command( + OUTPUT ${ublksrv_SOURCE_DIR}/lib/.libs/libublksrv.a + WORKING_DIRECTORY ${ublksrv_SOURCE_DIR} + COMMAND autoreconf -i + COMMAND ${CMAKE_COMMAND} -E env PKG_CONFIG_PATH=${LIBURING_PC_DIR} + ./configure + --without-libnfs --without-libiscsi --without-gnutls + # configure appends -fcoroutines whenever $CXX matches *g++* + # (GCC 10+ only, and only the ublk tool needs it, lib/ does not); + # use the name c++ to sidestep that match + CXX=c++ + "CFLAGS=-I${LIBURING_INCLUDE_DIRS} -O2" + "CXXFLAGS=-I${LIBURING_INCLUDE_DIRS} -O2" + "LDFLAGS=-L${liburing_SOURCE_DIR}/src" + COMMAND make -C lib -j + DEPENDS ${LIBURING_LIBRARIES} + COMMENT "Building libublksrv (lib/ only) with autotools" + VERBATIM + ) + add_custom_target(libublksrv_build DEPENDS ${ublksrv_SOURCE_DIR}/lib/.libs/libublksrv.a) + add_dependencies(libublksrv_build liburing_build) +endif() + +set(UBLKSRV_LIBRARIES ${ublksrv_SOURCE_DIR}/lib/.libs/libublksrv.a) +set(UBLKSRV_INCLUDE_DIRS ${ublksrv_SOURCE_DIR}/include) + +if(NOT TARGET libublksrv_static) + add_library(libublksrv_static STATIC IMPORTED GLOBAL) + set_target_properties(libublksrv_static PROPERTIES + IMPORTED_LOCATION ${UBLKSRV_LIBRARIES} + INTERFACE_INCLUDE_DIRECTORIES ${UBLKSRV_INCLUDE_DIRS} + # libublksrv.a references liburing symbols; propagate via INTERFACE so + # consumers get the correct link order automatically + INTERFACE_LINK_LIBRARIES liburing_static + ) +endif() +add_dependencies(libublksrv_static libublksrv_build) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(ublksrv DEFAULT_MSG UBLKSRV_LIBRARIES UBLKSRV_INCLUDE_DIRS) + +mark_as_advanced(UBLKSRV_LIBRARIES UBLKSRV_INCLUDE_DIRS) diff --git a/CMakeLists.txt b/CMakeLists.txt index cf5852a7..b3843fac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,10 +53,17 @@ set(ENABLE_MIMIC_VDSO off) option(BUILD_CURL_FROM_SOURCE "Compile static libcurl" off) option(BUILD_STREAM_CONVERTOR "Build the stream convertor" on) option(ORIGIN_EXT2FS "Use original libext2fs" off) +# Building it requires autotools on the build machine; disable with -DBUILD_UBLK_FRONTEND=off +option(BUILD_UBLK_FRONTEND "Build the ublk frontend (overlaybd-ublk), requires autotools" on) find_package(photon REQUIRED) find_package(tcmu REQUIRED) +if(BUILD_UBLK_FRONTEND) + find_package(liburing REQUIRED) + find_package(ublksrv REQUIRED) +endif() + if(BUILD_STREAM_CONVERTOR) find_package(yaml-cpp) if (NOT yaml-cpp_FOUND) diff --git a/README.md b/README.md index 0bc65424..9eac84bb 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,42 @@ cmake -D ENABLE_QAT=1 .. For more information go to `overlaybd/src/overlaybd/zfile/README.md`. +If you want to build the ublk frontend (`overlaybd-ublk`), which exposes an +image as `/dev/ublkbN` without going through TCMU/SCSI. It is built by default +(disable with `-D BUILD_UBLK_FRONTEND=off`); building it additionally requires +`autoconf`, `automake` and `libtool` (liburing and libublksrv are fetched and +built from source automatically). + +```bash +cmake -D BUILD_UBLK_FRONTEND=on .. +``` + +Running it requires a kernel with the `ublk_drv` driver (mainline >= 6.0, or a +distro kernel with ublk backported): + +```bash +sudo modprobe ublk_drv +sudo overlaybd-ublk add --config /path/to/config.v1.json # prints /dev/ublkbN when ready +sudo overlaybd-ublk list +sudo overlaybd-ublk del -n 0 +``` + +Unlike `overlaybd-tcmu`, one `overlaybd-ublk` process serves exactly one +device; killing the daemon removes the device. Each device can write to its +own log file via `add --log-path ...` (recommended when running multiple +devices; daemons otherwise share the global log file and are distinguished +by a `ublk-` tag). + +Notes: + +* Always unmount filesystems on `/dev/ublkbN` before `del` (or before stopping + the daemon): the daemon serves the device's IO, so tearing it down with a + mounted filesystem aborts in-flight journal writes. +* Runtime verified on a 5.10 kernel with ublk backported (Alinux + 5.10.134); a full regression on mainline 6.x kernels is still pending. + Known backport-kernel caveats: DISCARD is unavailable there, and `del` + takes ~2s instead of milliseconds. + Finally, setup a systemd service for overlaybd-tcmu backstore. ```bash diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 44b0030c..552a3481 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -75,6 +75,9 @@ if (NOT ORIGIN_EXT2FS) endif() add_subdirectory(tools) +if (BUILD_UBLK_FRONTEND) + add_subdirectory(ublk) +endif () if (BUILD_TESTING) add_subdirectory(test) endif () diff --git a/src/ublk/CMakeLists.txt b/src/ublk/CMakeLists.txt new file mode 100644 index 00000000..abf3fc98 --- /dev/null +++ b/src/ublk/CMakeLists.txt @@ -0,0 +1,35 @@ +# overlaybd-ublk: the ublk block device frontend (one process serves one device) +# Entered only when BUILD_UBLK_FRONTEND=on + +add_library(ublk_frontend_lib + io_dispatch.cpp + ublk_device.cpp + cli.cpp +) +target_include_directories(ublk_frontend_lib PUBLIC + ${PHOTON_INCLUDE_DIR} + ${RAPIDJSON_INCLUDE_DIRS} +) +target_link_libraries(ublk_frontend_lib + photon_static + overlaybd_image_lib + libublksrv_static # IMPORTED target; include dirs and liburing propagate via INTERFACE +) + +add_executable(overlaybd-ublk main.cpp) +target_link_libraries(overlaybd-ublk + ublk_frontend_lib + photon_static + overlaybd_image_lib + libublksrv_static + ${CURL_LIBRARIES} + ${OPENSSL_SSL_LIBRARY} + ${OPENSSL_CRYPTO_LIBRARY} + ${AIO_LIBRARIES} +) + +install(TARGETS overlaybd-ublk DESTINATION /opt/overlaybd/bin) + +if (BUILD_TESTING) + add_subdirectory(test) +endif () diff --git a/src/ublk/cli.cpp b/src/ublk/cli.cpp new file mode 100644 index 00000000..46526b1d --- /dev/null +++ b/src/ublk/cli.cpp @@ -0,0 +1,63 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#include "cli.h" + +#include "../tools/CLI11.hpp" + +int ublk_parse_cli(int argc, char **argv, UblkCliCmd &cmd) { + CLI::App app{"overlaybd-ublk: expose an overlaybd image as /dev/ublkbN " + "(one process serves one device)"}; + app.require_subcommand(1); + + auto *add = app.add_subcommand("add", "create a ublk device from an image config"); + add->add_option("--config", cmd.opts.image_config_path, + "overlaybd image config path (config.v1.json)") + ->required() + ->check(CLI::ExistingFile); + add->add_option("-n,--dev-id", cmd.opts.dev_id, + "device id (default -1: allocated by kernel)"); + add->add_option("--depth", cmd.opts.queue_depth, "queue depth") + ->default_val(128) + ->check(CLI::Range(1, 4096)); + add->add_option("--service-config", cmd.opts.service_config_path, + "global service config path (default /etc/overlaybd/overlaybd.json)"); + add->add_option("--log-path", cmd.opts.log_path, + "per-device log file (default: shared log from service config; " + "recommended when running multiple devices)"); + add->add_flag("--foreground", cmd.foreground, "run in foreground (no daemonize)"); + + auto *del = app.add_subcommand("del", "stop the daemon serving /dev/ublkbN"); + del->add_option("-n,--dev-id", cmd.del_dev_id, "device id")->required(); + + app.add_subcommand("list", "list overlaybd-ublk devices on this host"); + + try { + app.parse(argc, argv); + } catch (const CLI::ParseError &e) { + // help returns 0, errors return non-zero; cmd.kind stays NONE in + // both cases so the caller just exits with this code + cmd.kind = UblkCliCmd::Kind::NONE; + return app.exit(e); + } + + if (app.got_subcommand("add")) + cmd.kind = UblkCliCmd::Kind::ADD; + else if (app.got_subcommand("del")) + cmd.kind = UblkCliCmd::Kind::DEL; + else + cmd.kind = UblkCliCmd::Kind::LIST; + return 0; +} diff --git a/src/ublk/cli.h b/src/ublk/cli.h new file mode 100644 index 00000000..94946f1c --- /dev/null +++ b/src/ublk/cli.h @@ -0,0 +1,34 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#pragma once + +#include "ublk_device.h" + +// Parsed CLI command, kept apart from execution for unit testing. +struct UblkCliCmd { + enum class Kind { NONE, ADD, DEL, LIST }; + Kind kind = Kind::NONE; + + UblkDeviceOpts opts; // ADD + bool foreground = false; + + int del_dev_id = -1; // DEL +}; + +// Parse argv into cmd. On success returns 0 with cmd.kind set; on +// error/help prints the message (CLI11 behavior), leaves cmd.kind as NONE +// and returns the intended process exit code (0 for help). +int ublk_parse_cli(int argc, char **argv, UblkCliCmd &cmd); diff --git a/src/ublk/io_dispatch.cpp b/src/ublk/io_dispatch.cpp new file mode 100644 index 00000000..176aa97d --- /dev/null +++ b/src/ublk/io_dispatch.cpp @@ -0,0 +1,57 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#include "io_dispatch.h" + +#include + +#include + +int ublk_dispatch_io(UblkIOTarget *tgt, uint8_t op, uint64_t offset_bytes, + uint32_t len_bytes, void *buf) { + switch (op) { + case UBLK_IO_OP_READ: { + struct iovec iov { + buf, len_bytes + }; + ssize_t ret = tgt->preadv(&iov, 1, offset_bytes); + // partial read is an error, same as the TCMU frontend + return (ret == (ssize_t)len_bytes) ? (int)ret : -EIO; + } + + case UBLK_IO_OP_WRITE: { + struct iovec iov { + buf, len_bytes + }; + ssize_t ret = tgt->pwritev(&iov, 1, offset_bytes); + if (ret == (ssize_t)len_bytes) + return (int)ret; + // keep the TCMU frontend's EROFS special case (write to sealed image) + return (errno == EROFS) ? -EROFS : -EIO; + } + + case UBLK_IO_OP_FLUSH: + return (tgt->fdatasync() == 0) ? 0 : -EIO; + + case UBLK_IO_OP_DISCARD: + case UBLK_IO_OP_WRITE_ZEROES: + // same as the TCMU WRITE_SAME+UNMAP path: + // fallocate(3) = FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE + return (tgt->fallocate(3, offset_bytes, len_bytes) == 0) ? 0 : -EIO; + + default: + return -ENOTSUP; + } +} diff --git a/src/ublk/io_dispatch.h b/src/ublk/io_dispatch.h new file mode 100644 index 00000000..0d0760d8 --- /dev/null +++ b/src/ublk/io_dispatch.h @@ -0,0 +1,41 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#pragma once + +#include +#include +#include + +// IO dispatch layer decoupled from the ublksrv event loop: +// keeps the mapping unit-testable without a ublk-capable kernel, and +// replaceable if the event-loop integration ever has to fall back to the +// official demo_event (two-thread) model. +// +// UblkIOTarget is the minimal seam over ImageFile; unit tests substitute +// an in-memory implementation. +struct UblkIOTarget { + virtual ~UblkIOTarget() = default; + virtual ssize_t preadv(const struct iovec *iov, int iovcnt, off_t offset) = 0; + virtual ssize_t pwritev(const struct iovec *iov, int iovcnt, off_t offset) = 0; + virtual int fdatasync() = 0; + virtual int fallocate(int mode, off_t offset, off_t len) = 0; +}; + +// Map one ublk io descriptor (op + byte range) to an UblkIOTarget call. +// Follows ublk completion semantics: returns transferred bytes on success +// (0 for FLUSH/DISCARD/WRITE_ZEROES), negative errno on failure. +int ublk_dispatch_io(UblkIOTarget *tgt, uint8_t op, uint64_t offset_bytes, + uint32_t len_bytes, void *buf); diff --git a/src/ublk/main.cpp b/src/ublk/main.cpp new file mode 100644 index 00000000..8bf9e2a2 --- /dev/null +++ b/src/ublk/main.cpp @@ -0,0 +1,193 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#include "cli.h" +#include "ublk_device.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +static int read_pidfile(int dev_id, pid_t *pid) { + char path[128]; + snprintf(path, sizeof(path), "%s/%d.pid", OVERLAYBD_UBLK_RUN_DIR, dev_id); + FILE *fp = fopen(path, "r"); + if (fp == nullptr) + return -1; + long v = 0; + int n = fscanf(fp, "%ld", &v); + fclose(fp); + if (n != 1 || v <= 0) + return -1; + *pid = (pid_t)v; + return 0; +} + +// del: process == device, so a graceful stop is SIGTERM to the +// daemon, whose signal handler tears the ublk device down before exiting. +static int cmd_del(int dev_id) { + pid_t pid; + if (read_pidfile(dev_id, &pid) != 0) { + fprintf(stderr, "overlaybd-ublk: no pidfile for dev %d under %s\n", dev_id, + OVERLAYBD_UBLK_RUN_DIR); + return 1; + } + if (kill(pid, SIGTERM) != 0) { + if (errno == ESRCH) { + fprintf(stderr, "overlaybd-ublk: dev %d daemon (pid %d) not running, " + "removing stale pidfile\n", + dev_id, (int)pid); + char path[128]; + snprintf(path, sizeof(path), "%s/%d.pid", OVERLAYBD_UBLK_RUN_DIR, dev_id); + unlink(path); + return 1; + } + fprintf(stderr, "overlaybd-ublk: kill pid %d failed: %s\n", (int)pid, + strerror(errno)); + return 1; + } + // wait for the daemon to finish teardown (device gone when it exits) + for (int i = 0; i < 300; i++) { // up to 30s + if (kill(pid, 0) != 0 && errno == ESRCH) { + printf("/dev/ublkb%d removed\n", dev_id); + return 0; + } + usleep(100 * 1000); + } + fprintf(stderr, "overlaybd-ublk: dev %d daemon (pid %d) did not exit in 30s\n", dev_id, + (int)pid); + return 1; +} + +static int cmd_list() { + DIR *dir = opendir(OVERLAYBD_UBLK_RUN_DIR); + if (dir == nullptr) + return 0; // nothing ever started on this host + struct dirent *ent; + while ((ent = readdir(dir)) != nullptr) { + int dev_id; + if (sscanf(ent->d_name, "%d.pid", &dev_id) != 1) + continue; + pid_t pid; + if (read_pidfile(dev_id, &pid) != 0) + continue; + bool alive = (kill(pid, 0) == 0); + // best effort: show the image config from the daemon's cmdline + std::string cmdline; + char proc_path[64], buf[4096]; + snprintf(proc_path, sizeof(proc_path), "/proc/%d/cmdline", (int)pid); + int fd = open(proc_path, O_RDONLY); + if (fd >= 0) { + ssize_t n = read(fd, buf, sizeof(buf) - 1); + close(fd); + for (ssize_t i = 0; i < n; i++) + buf[i] = buf[i] ? buf[i] : ' '; + if (n > 0) { + buf[n] = 0; + cmdline = buf; + } + } + printf("/dev/ublkb%-4d pid %-8d %-8s %s\n", dev_id, (int)pid, + alive ? "running" : "dead", cmdline.c_str()); + } + closedir(dir); + return 0; +} + +// add: fork the daemon and wait for the sync-ready contract -- the parent +// only exits 0 after the device path arrived over the pipe, and that path +// is the single authoritative stdout output. +static int cmd_add(const UblkDeviceOpts &opts, bool foreground) { + if (ublk_check_control_dev() != 0) + return 1; + + if (foreground) + return run_ublk_device(opts, STDOUT_FILENO); + + int pipefd[2]; + if (pipe(pipefd) != 0) { + fprintf(stderr, "overlaybd-ublk: pipe failed: %s\n", strerror(errno)); + return 1; + } + + pid_t child = fork(); + if (child < 0) { + fprintf(stderr, "overlaybd-ublk: fork failed: %s\n", strerror(errno)); + return 1; + } + + if (child == 0) { + // daemon child: detach from the terminal; logs go to the log file + // configured in the global service config (alog), not the console + close(pipefd[0]); + setsid(); + int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, STDIN_FILENO); + dup2(devnull, STDOUT_FILENO); + dup2(devnull, STDERR_FILENO); + if (devnull > 2) + close(devnull); + } + exit(run_ublk_device(opts, pipefd[1])); + } + + // parent: block until the child reports readiness or dies (pipe EOF) + close(pipefd[1]); + char buf[64]; + ssize_t n = 0, off = 0; + while (off < (ssize_t)sizeof(buf) - 1 && + (n = read(pipefd[0], buf + off, sizeof(buf) - 1 - off)) > 0) { + off += n; + if (memchr(buf, '\n', off) != nullptr) + break; + } + close(pipefd[0]); + + if (off > 0 && strncmp(buf, "/dev/", 5) == 0) { + fwrite(buf, 1, off, stdout); // e.g. "/dev/ublkb0\n" + return 0; + } + fprintf(stderr, "overlaybd-ublk: device failed to start " + "(check the overlaybd log for details)\n"); + return 1; +} + +int main(int argc, char **argv) { + UblkCliCmd cmd; + int ret = ublk_parse_cli(argc, argv, cmd); + if (cmd.kind == UblkCliCmd::Kind::NONE) + return ret; + + switch (cmd.kind) { + case UblkCliCmd::Kind::ADD: + return cmd_add(cmd.opts, cmd.foreground); + case UblkCliCmd::Kind::DEL: + return cmd_del(cmd.del_dev_id); + case UblkCliCmd::Kind::LIST: + return cmd_list(); + default: + return 1; + } +} diff --git a/src/ublk/test/CMakeLists.txt b/src/ublk/test/CMakeLists.txt new file mode 100644 index 00000000..80d6b727 --- /dev/null +++ b/src/ublk/test/CMakeLists.txt @@ -0,0 +1,21 @@ +include_directories($ENV{GFLAGS}/include) +link_directories($ENV{GFLAGS}/lib) + +include_directories($ENV{GTEST}/googletest/include) +link_directories($ENV{GTEST}/lib) + +# Compile only the sources under test instead of linking ublk_frontend_lib: +# io_dispatch/cli are pure logic decoupled from the event loop, so the tests +# need neither a ublk-capable kernel nor photon +add_executable(ublk_dispatch_test + ublk_dispatch_test.cpp + ../io_dispatch.cpp + ../cli.cpp +) +target_include_directories(ublk_dispatch_test PUBLIC ${UBLKSRV_INCLUDE_DIRS} ${LIBURING_INCLUDE_DIRS}) +target_link_libraries(ublk_dispatch_test gtest gtest_main pthread) + +add_test( + NAME ublk_dispatch_test + COMMAND ${EXECUTABLE_OUTPUT_PATH}/ublk_dispatch_test +) diff --git a/src/ublk/test/ublk_dispatch_test.cpp b/src/ublk/test/ublk_dispatch_test.cpp new file mode 100644 index 00000000..2717ef5c --- /dev/null +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -0,0 +1,225 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#include "../cli.h" +#include "../io_dispatch.h" + +#include +#include + +#include +#include +#include + +// --------------------------------------------------------------------------- +// io_dispatch: in-memory UblkIOTarget covering the ImageFile seam +// --------------------------------------------------------------------------- + +class MemTarget : public UblkIOTarget { +public: + explicit MemTarget(size_t size) : data(size, 0) { + } + + ssize_t preadv(const struct iovec *iov, int iovcnt, off_t offset) override { + if (fail_read) { + errno = EIO; + return -1; + } + ssize_t done = 0; + for (int i = 0; i < iovcnt; i++) { + size_t n = iov[i].iov_len; + if (short_read && n > 0) + n -= 1; // simulate partial read + memcpy(iov[i].iov_base, data.data() + offset + done, n); + done += n; + if (short_read) + break; + } + return done; + } + + ssize_t pwritev(const struct iovec *iov, int iovcnt, off_t offset) override { + if (write_errno != 0) { + errno = write_errno; + return -1; + } + ssize_t done = 0; + for (int i = 0; i < iovcnt; i++) { + memcpy(data.data() + offset + done, iov[i].iov_base, iov[i].iov_len); + done += iov[i].iov_len; + } + return done; + } + + int fdatasync() override { + sync_cnt++; + return sync_ret; + } + + int fallocate(int mode, off_t offset, off_t len) override { + last_fallocate_mode = mode; + if (fallocate_ret == 0) + memset(data.data() + offset, 0, len); // punch hole => read zeros + return fallocate_ret; + } + + std::vector data; + bool fail_read = false; + bool short_read = false; + int write_errno = 0; + int sync_ret = 0; + int sync_cnt = 0; + int fallocate_ret = 0; + int last_fallocate_mode = -1; +}; + +TEST(io_dispatch, write_then_read_roundtrip) { + MemTarget tgt(1 << 20); + char wbuf[4096], rbuf[4096]; + memset(wbuf, 0xab, sizeof(wbuf)); + + int ret = ublk_dispatch_io(&tgt, UBLK_IO_OP_WRITE, 8192, sizeof(wbuf), wbuf); + EXPECT_EQ(ret, (int)sizeof(wbuf)); + + ret = ublk_dispatch_io(&tgt, UBLK_IO_OP_READ, 8192, sizeof(rbuf), rbuf); + EXPECT_EQ(ret, (int)sizeof(rbuf)); + EXPECT_EQ(memcmp(wbuf, rbuf, sizeof(rbuf)), 0); +} + +TEST(io_dispatch, read_failure_maps_to_eio) { + MemTarget tgt(1 << 20); + char buf[512]; + tgt.fail_read = true; + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_READ, 0, sizeof(buf), buf), -EIO); +} + +TEST(io_dispatch, partial_read_is_an_error) { + MemTarget tgt(1 << 20); + char buf[512]; + tgt.short_read = true; + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_READ, 0, sizeof(buf), buf), -EIO); +} + +TEST(io_dispatch, write_erofs_is_propagated) { + MemTarget tgt(1 << 20); + char buf[512] = {}; + tgt.write_errno = EROFS; + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_WRITE, 0, sizeof(buf), buf), -EROFS); + tgt.write_errno = ENOSPC; // any other write error becomes EIO + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_WRITE, 0, sizeof(buf), buf), -EIO); +} + +TEST(io_dispatch, flush_maps_to_fdatasync) { + MemTarget tgt(1 << 20); + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_FLUSH, 0, 0, nullptr), 0); + EXPECT_EQ(tgt.sync_cnt, 1); + tgt.sync_ret = -1; + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_FLUSH, 0, 0, nullptr), -EIO); +} + +TEST(io_dispatch, discard_and_write_zeroes_punch_hole) { + MemTarget tgt(1 << 20); + char wbuf[4096], rbuf[4096], zero[4096] = {}; + memset(wbuf, 0xcd, sizeof(wbuf)); + ublk_dispatch_io(&tgt, UBLK_IO_OP_WRITE, 0, sizeof(wbuf), wbuf); + + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_DISCARD, 0, sizeof(wbuf), nullptr), 0); + // fallocate(3) = FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, as in tcmu + EXPECT_EQ(tgt.last_fallocate_mode, 3); + ublk_dispatch_io(&tgt, UBLK_IO_OP_READ, 0, sizeof(rbuf), rbuf); + EXPECT_EQ(memcmp(rbuf, zero, sizeof(rbuf)), 0); + + tgt.last_fallocate_mode = -1; + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_WRITE_ZEROES, 0, 4096, nullptr), 0); + EXPECT_EQ(tgt.last_fallocate_mode, 3); + + tgt.fallocate_ret = -1; + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_DISCARD, 0, 4096, nullptr), -EIO); +} + +TEST(io_dispatch, unknown_op_is_rejected) { + MemTarget tgt(4096); + EXPECT_EQ(ublk_dispatch_io(&tgt, UBLK_IO_OP_ZONE_APPEND, 0, 0, nullptr), -ENOTSUP); +} + +// --------------------------------------------------------------------------- +// cli parsing +// --------------------------------------------------------------------------- + +static int parse(std::vector args, UblkCliCmd &cmd) { + args.insert(args.begin(), "overlaybd-ublk"); + return ublk_parse_cli((int)args.size(), (char **)args.data(), cmd); +} + +TEST(cli, add_requires_existing_config) { + UblkCliCmd cmd; + EXPECT_NE(parse({"add", "--config", "/nonexistent/config.v1.json"}, cmd), 0); + EXPECT_EQ(cmd.kind, UblkCliCmd::Kind::NONE); +} + +TEST(cli, add_parses_options) { + UblkCliCmd cmd; + // use this test binary itself as an always-existing file path + char self[] = "/proc/self/exe"; + ASSERT_EQ(parse({"add", "--config", self, "-n", "3", "--depth", "64", "--foreground"}, + cmd), + 0); + EXPECT_EQ(cmd.kind, UblkCliCmd::Kind::ADD); + EXPECT_EQ(cmd.opts.dev_id, 3); + EXPECT_EQ(cmd.opts.queue_depth, 64); + EXPECT_TRUE(cmd.foreground); + EXPECT_EQ(cmd.opts.image_config_path, self); +} + +TEST(cli, add_defaults) { + UblkCliCmd cmd; + char self[] = "/proc/self/exe"; + ASSERT_EQ(parse({"add", "--config", self}, cmd), 0); + EXPECT_EQ(cmd.opts.dev_id, -1); // kernel allocates + EXPECT_EQ(cmd.opts.queue_depth, 128); // documented default + EXPECT_FALSE(cmd.foreground); + EXPECT_TRUE(cmd.opts.service_config_path.empty()); + EXPECT_TRUE(cmd.opts.log_path.empty()); // default: shared log +} + +TEST(cli, add_log_path) { + UblkCliCmd cmd; + char self[] = "/proc/self/exe"; + ASSERT_EQ(parse({"add", "--config", self, "--log-path", "/var/log/obd-ublk0.log"}, + cmd), + 0); + EXPECT_EQ(cmd.opts.log_path, "/var/log/obd-ublk0.log"); +} + +TEST(cli, del_requires_dev_id) { + UblkCliCmd cmd; + EXPECT_NE(parse({"del"}, cmd), 0); + EXPECT_EQ(cmd.kind, UblkCliCmd::Kind::NONE); + + UblkCliCmd cmd2; + ASSERT_EQ(parse({"del", "-n", "5"}, cmd2), 0); + EXPECT_EQ(cmd2.kind, UblkCliCmd::Kind::DEL); + EXPECT_EQ(cmd2.del_dev_id, 5); +} + +TEST(cli, list_and_missing_subcommand) { + UblkCliCmd cmd; + ASSERT_EQ(parse({"list"}, cmd), 0); + EXPECT_EQ(cmd.kind, UblkCliCmd::Kind::LIST); + + UblkCliCmd cmd2; + EXPECT_NE(parse({}, cmd2), 0); // subcommand is mandatory + EXPECT_EQ(cmd2.kind, UblkCliCmd::Kind::NONE); +} diff --git a/src/ublk/ublk_device.cpp b/src/ublk/ublk_device.cpp new file mode 100644 index 00000000..cf888402 --- /dev/null +++ b/src/ublk/ublk_device.cpp @@ -0,0 +1,457 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#include "ublk_device.h" +#include "io_dispatch.h" + +#include "../image_file.h" +#include "../image_service.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +// Single device per process (the frontend's core design decision), so +// file-scope state is fine, mirroring the tcmu frontend's main.cpp style. +static ImageService *g_imgservice = nullptr; +static ImageFile *g_file = nullptr; +static struct ublksrv_ctrl_dev *g_ctrl_dev = nullptr; +static std::atomic g_queue_exited{false}; + +int ublk_check_control_dev() { + if (access(UBLK_CONTROL_DEV, F_OK) == 0) + return 0; + fprintf(stderr, + "overlaybd-ublk: %s not found.\n" + "The ublk frontend requires the ublk_drv kernel driver " + "(mainline kernel >= 6.0, or a distro kernel with ublk backported).\n" + "Try: modprobe ublk_drv\n", + UBLK_CONTROL_DEV); + return -1; +} + +// ImageFile adapter over the UblkIOTarget seam. Reads carry the same +// retry-forever semantics as the TCMU frontend's sure() helper; writes +// are single-shot (EROFS etc. must surface immediately). +class ImageFileTarget : public UblkIOTarget { +public: + explicit ImageFileTarget(ImageFile *file) : m_file(file) { + } + + ssize_t preadv(const struct iovec *iov, int iovcnt, off_t offset) override { + auto time_st = photon::now; + uint64_t try_cnt = 0, sleep_period = 20UL * 1000; + again: + if (photon::now - time_st > 7LL * 24 * 60 * 60 * 1000 * 1000 /*7days*/) { + LOG_ERROR_RETURN(EIO, -1, "sure request timeout, offset: `", offset); + } + ssize_t ret = m_file->preadv(iov, iovcnt, offset); + if (ret >= 0) { + return ret; + } + if (try_cnt % 10 == 0) { + LOG_ERROR("io request failed, offset: `, ret: `, retry times: `, errno:`", offset, + ret, try_cnt, errno); + } + try_cnt++; + photon::thread_usleep(sleep_period); + sleep_period = std::min(sleep_period * 2, 30UL * 1000 * 1000); + goto again; + } + + ssize_t pwritev(const struct iovec *iov, int iovcnt, off_t offset) override { + return m_file->pwritev(iov, iovcnt, offset); + } + + int fdatasync() override { + return m_file->fdatasync(); + } + + int fallocate(int mode, off_t offset, off_t len) override { + return m_file->fallocate(mode, offset, len); + } + +private: + ImageFile *m_file; +}; + +static ImageFileTarget *g_target = nullptr; + +// --------------------------------------------------------------------------- +// data plane: per-queue photon-owned hybrid event loop +// --------------------------------------------------------------------------- + +struct QueueCtx { + const struct ublksrv_queue *q = nullptr; + photon::ThreadPool<32> *pool = nullptr; + uint32_t inflight = 0; +}; + +struct IOJob { + const struct ublksrv_queue *q; + const struct ublk_io_data *data; +}; + +static void *io_worker(void *arg) { + IOJob *job = (IOJob *)arg; + auto *ctx = (QueueCtx *)job->q->private_data; + const struct ublksrv_io_desc *iod = job->data->iod; + + int res = ublk_dispatch_io(g_target, ublksrv_get_op(iod), iod->start_sector << 9, + iod->nr_sectors << 9, + ublksrv_queue_get_io_buf(job->q, job->data->tag)); + + ublksrv_complete_io(job->q, job->data->tag, res); + // Pitfall: complete_io only queues the COMMIT SQE; nobody else + // submits it in this integration mode, so submit right here (same thread + // as the queue loop, no eventfd needed). + io_uring_submit(job->q->ring_ptr); + + ctx->inflight--; + delete job; + return nullptr; +} + +static int obd_handle_io_async(const struct ublksrv_queue *q, const struct ublk_io_data *data) { + auto *ctx = (QueueCtx *)q->private_data; + ctx->inflight++; + ctx->pool->thread_create(&io_worker, new IOJob{q, data}); + return 0; +} + +static int obd_init_tgt(struct ublksrv_dev *dev, int type, int argc, char *argv[]) { + const struct ublksrv_ctrl_dev_info *info = + ublksrv_ctrl_get_dev_info(ublksrv_get_ctrl_dev(dev)); + dev->tgt.dev_size = g_file->num_lbas * g_file->block_size; + dev->tgt.tgt_ring_depth = info->queue_depth; + dev->tgt.nr_fds = 0; + return 0; +} + +static struct ublksrv_tgt_type obd_tgt_type = { + .handle_io_async = obd_handle_io_async, + .init_tgt = obd_init_tgt, + .name = "overlaybd", +}; + +// The queue thread owns its own photon env and acts as the event-loop master: +// poll the io_uring ring fd via photon epoll, reap CQEs only when readable +// (never enter ublksrv_process_io's blocking wait), submit queued SQEs after +// each round. This is safe with ublksrv v1.7: ring_ptr is a public field, +// reap_events is a public zero-wait API, and submit_and_wait returns +// immediately when the CQ is non-empty. +// +// Runtime-verified on a 5.10 kernel with ublk backported (startup sequence, +// idle wakeup, full-queue fio pressure); a regression pass on mainline 6.x +// kernels is still pending. +static void queue_thread_fn(const struct ublksrv_dev *dev, photon::semaphore *started, + int *init_ret) { + photon::init(photon::INIT_EVENT_EPOLL, photon::INIT_IO_LIBCURL); + DEFER(photon::fini()); + + photon::ThreadPool<32> pool; + QueueCtx ctx; + ctx.pool = &pool; + + // IORING_SETUP_COOP_TASKRUN (mainline 5.19+) is a pure optimization, but + // ublksrv_queue_init() hardcodes it. Backport kernels shipping ublk_drv on + // older baselines (e.g. Alinux 5.10.134) may lack it and fail with EINVAL, + // so retry without the flag before giving up. + const struct ublksrv_queue *q = + ublksrv_queue_init_flags(dev, 0, &ctx, IORING_SETUP_COOP_TASKRUN); + if (q == nullptr) { + LOG_WARN("queue init with IORING_SETUP_COOP_TASKRUN failed, retrying without it"); + q = ublksrv_queue_init_flags(dev, 0, &ctx, 0); + } + if (q == nullptr) { + LOG_ERROR("ublksrv_queue_init failed (see syslog for libublksrv details)"); + *init_ret = -1; + g_queue_exited.store(true); + started->signal(1); + return; + } + ctx.q = q; + + // flush the initial FETCH SQEs prepared by ublksrv_queue_init, otherwise + // the kernel never sees them and the loop below waits forever + io_uring_submit(q->ring_ptr); + + *init_ret = 0; + started->signal(1); + + while (true) { + int ret = photon::wait_for_fd_readable(q->ring_ptr->ring_fd, 1000UL * 1000); + if (ret == 0) { + // CQEs pending: reap without waiting; this drives handle_io_async + // and the internal queue state machine (STOPPING etc.) + ublksrv_queue_reap_events(q); + } else if (errno != ETIMEDOUT) { + LOG_ERROR("wait on ring fd failed: `", strerror(errno)); + break; + } + // submit FETCH/COMMIT SQEs queued during the reap round + io_uring_submit(q->ring_ptr); + + if ((ublksrv_queue_state(q) & UBLKSRV_QUEUE_STOPPING) && ctx.inflight == 0) + break; + } + + // Drain phase: official daemons only deinit after ublksrv_process_io() + // returns -ENODEV, i.e. every io command (FETCH) has been completed or + // canceled by the kernel (cmd_inflight == 0, tracked inside libublksrv). + // Closing the ring with uring_cmds still inflight leaks ublk device + // references on kernels lacking uring_cmd cancel-on-exit (observed on the + // 5.10 backport), which makes the later UBLK_CMD_DEL_DEV wait forever in + // ublk_ctrl_del_dev. Blocking here is fine: all target IO has finished, + // only cancellation CQEs remain. + while (ublksrv_process_io(q) >= 0) + ; + + LOG_INFO("ublk queue exited"); + ublksrv_queue_deinit(q); + g_queue_exited.store(true); +} + +// --------------------------------------------------------------------------- +// control plane: ctrl dev lifecycle, owned by the main thread +// --------------------------------------------------------------------------- + +static void set_dev_parameters(struct ublksrv_ctrl_dev *cdev, ImageFile *file) { + const struct ublksrv_ctrl_dev_info *info = ublksrv_ctrl_get_dev_info(cdev); + uint8_t bs_shift = (uint8_t)__builtin_ctz(file->block_size); + + struct ublk_params p; + memset(&p, 0, sizeof(p)); + p.types = UBLK_PARAM_TYPE_BASIC | UBLK_PARAM_TYPE_DISCARD; + // honest cache declaration: writable images have a volatile + // cache (FLUSH -> ImageFile::fdatasync); read-only images have nothing + // volatile to flush, and advertising a cache would make the kernel send + // FLUSH that fdatasync() on a RO ImageFile fails with (dmesg: + // "lost sync page write"), so declare no cache for them + if (file->read_only) + p.basic.attrs = UBLK_ATTR_READ_ONLY; + else + p.basic.attrs = UBLK_ATTR_VOLATILE_CACHE; + p.basic.logical_bs_shift = bs_shift; + p.basic.physical_bs_shift = 12; + p.basic.io_opt_shift = 12; + p.basic.io_min_shift = bs_shift; + p.basic.max_sectors = info->max_io_buf_bytes >> 9; + p.basic.dev_sectors = (file->num_lbas * file->block_size) >> 9; + // TCMU equivalent: tcmu_dev_set_unmap_enabled(true) + p.discard.discard_granularity = file->block_size; + p.discard.max_discard_sectors = info->max_io_buf_bytes >> 9; + p.discard.max_write_zeroes_sectors = info->max_io_buf_bytes >> 9; + p.discard.max_discard_segments = 1; + + int ret = ublksrv_ctrl_set_params(cdev, &p); + if (ret) + LOG_ERROR("ublk dev ` set params failed: `", info->dev_id, ret); +} + +static void stop_device_handler(int signal) { + LOG_INFO("signal ` received, stopping ublk device", signal); + if (g_ctrl_dev != nullptr) + ublksrv_ctrl_stop_dev(g_ctrl_dev); +} + +static void notify_ready(int ready_fd, int dev_id) { + if (ready_fd < 0) + return; + char buf[64]; + int len = snprintf(buf, sizeof(buf), "/dev/ublkb%d\n", dev_id); + ssize_t nwritten = write(ready_fd, buf, len); + if (nwritten != len) + LOG_ERROR("failed to notify readiness: `", strerror(errno)); + if (ready_fd > 2) + close(ready_fd); +} + +// Delete the ublk device and release the ctrl dev, without ever blocking +// this process forever. Sync DEL_DEV waits in-kernel until every device +// reference is dropped; on some kernels (observed: 5.10 backport) the last +// reference only goes away at process exit, turning the sync wait into a +// deadlock. So: prefer DEL_DEV_ASYNC (kernel 6.5+); otherwise run sync del +// on a helper thread with a bounded wait, and if it is still blocked just +// exit -- process teardown drops the references and the kernel completes +// the pending deletion (verified: a hung DEL finished exactly at process +// death and the dev id was reclaimed). +static void ctrl_del_and_deinit() { + int ret = ublksrv_ctrl_del_dev_async(g_ctrl_dev); + if (ret < 0) { + LOG_WARN("DEL_DEV_ASYNC unavailable (`), falling back to bounded sync del", ret); + auto *done = new std::atomic(false); + struct ublksrv_ctrl_dev *cdev = g_ctrl_dev; + std::thread del_thread([done, cdev] { + ublksrv_ctrl_del_dev(cdev); + done->store(true); + }); + for (int i = 0; i < 20 && !done->load(); i++) + photon::thread_usleep(100 * 1000); // up to 2s: healthy kernels finish in ms + if (!done->load()) { + // photon LOG macros print adjacent string literals with quotes, keep one literal + LOG_WARN("DEL_DEV still blocked after 2s, exiting anyway; process teardown releases the last device references and the kernel finishes the deletion"); + del_thread.detach(); + // deliberately leak `done` and skip ctrl_deinit: the ctrl ring is + // still in use by the blocked command, process exit reclaims both + g_ctrl_dev = nullptr; + return; + } + del_thread.join(); + delete done; + } + ublksrv_ctrl_deinit(g_ctrl_dev); + g_ctrl_dev = nullptr; +} + +int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { + if (ublk_check_control_dev() != 0) + return 1; + mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); // pidfile dir for libublksrv + + photon::init(photon::INIT_EVENT_DEFAULT, photon::INIT_IO_DEFAULT); + photon::block_all_signal(); + photon::sync_signal(SIGTERM, &stop_device_handler); + photon::sync_signal(SIGINT, &stop_device_handler); + + g_imgservice = create_image_service( + opts.service_config_path.empty() ? nullptr : opts.service_config_path.c_str()); + if (g_imgservice == nullptr) { + LOG_ERROR("failed to create image service"); + return 1; + } + // per-device log: redirect after create_image_service (which applied the + // shared logPath from the global config); reuse the global size/rotation + if (!opts.log_path.empty()) { + // APPCFG defaults (10MB x 3) also cover old-style configs without logConfig + uint64_t log_size = 1024UL * 1024 * g_imgservice->global_conf.logConfig().logSizeMB(); + int ret = log_output_file(opts.log_path.c_str(), log_size, + g_imgservice->global_conf.logConfig().logRotateNum()); + if (ret != 0) + LOG_ERROR("failed to set per-device log path `, keep shared log", + opts.log_path.c_str()); + else + LOG_INFO("per-device log: `", opts.log_path.c_str()); + } + // tag with pid so that daemons sharing one log remain distinguishable + std::string dev_tag = "ublk-" + std::to_string(getpid()); + // open the image before anything ublk-related: dev_size is needed by + // init_tgt, and a broken config must fail the add command, not the device + g_file = g_imgservice->create_image_file(opts.image_config_path.c_str(), dev_tag); + if (g_file == nullptr) { + LOG_ERROR("failed to open image `", opts.image_config_path.c_str()); + return 1; + } + g_target = new ImageFileTarget(g_file); + + struct ublksrv_dev_data data; + memset(&data, 0, sizeof(data)); + data.dev_id = opts.dev_id; + data.max_io_buf_bytes = 512 * 1024; + data.nr_hw_queues = 1; // v1: single queue, one event-loop thread + data.queue_depth = (unsigned short)opts.queue_depth; + data.tgt_type = "overlaybd"; + data.tgt_ops = &obd_tgt_type; + data.run_dir = OVERLAYBD_UBLK_RUN_DIR; + data.flags = 0; + + g_ctrl_dev = ublksrv_ctrl_init(&data); + if (g_ctrl_dev == nullptr) { + LOG_ERROR("ublksrv_ctrl_init failed"); + return 1; + } + + int ret = ublksrv_ctrl_add_dev(g_ctrl_dev); + if (ret < 0) { + LOG_ERROR("ublksrv_ctrl_add_dev failed: `", ret); + ublksrv_ctrl_deinit(g_ctrl_dev); + return 1; + } + int dev_id = ublksrv_ctrl_get_dev_info(g_ctrl_dev)->dev_id; + + ret = ublksrv_ctrl_get_affinity(g_ctrl_dev); + if (ret < 0) + LOG_WARN("ublksrv_ctrl_get_affinity failed: `", ret); + + { + const struct ublksrv_dev *dev = ublksrv_dev_init(g_ctrl_dev); + if (dev == nullptr) { + LOG_ERROR("ublksrv_dev_init failed"); + goto fail_del_dev; + } + + photon::semaphore queue_started; + int queue_init_ret = -1; + std::thread queue_thread(queue_thread_fn, dev, &queue_started, &queue_init_ret); + queue_started.wait(1); + if (queue_init_ret != 0) { + queue_thread.join(); + ublksrv_dev_deinit(dev); + goto fail_del_dev; + } + + set_dev_parameters(g_ctrl_dev, g_file); + + ret = ublksrv_ctrl_start_dev(g_ctrl_dev, getpid()); + if (ret < 0) { + LOG_ERROR("ublksrv_ctrl_start_dev failed: `", ret); + ublksrv_ctrl_stop_dev(g_ctrl_dev); // unblock the queue thread + while (!g_queue_exited.load()) + photon::thread_usleep(10 * 1000); + queue_thread.join(); + ublksrv_dev_deinit(dev); + goto fail_del_dev; + } + + LOG_INFO("overlaybd-ublk device /dev/ublkb` ready, image: `", dev_id, + opts.image_config_path.c_str()); + notify_ready(ready_fd, dev_id); + + // photon-sleep instead of a bare join: sync_signal handlers run as + // photon coroutines in this thread and must keep getting scheduled + while (!g_queue_exited.load()) + photon::thread_usleep(200 * 1000); + queue_thread.join(); + ublksrv_dev_deinit(dev); + } + + ctrl_del_and_deinit(); + + delete g_target; + delete g_file; + delete g_imgservice; + LOG_INFO("overlaybd-ublk exited"); + return 0; + +fail_del_dev: + ctrl_del_and_deinit(); + return 1; +} diff --git a/src/ublk/ublk_device.h b/src/ublk/ublk_device.h new file mode 100644 index 00000000..4bd7ac19 --- /dev/null +++ b/src/ublk/ublk_device.h @@ -0,0 +1,46 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#pragma once + +#include + +// pidfiles (.pid, created by libublksrv) live here; `del` and `list` +// discover overlaybd-ublk daemons through this directory only. +#define OVERLAYBD_UBLK_RUN_DIR "/var/run/overlaybd-ublk" +#define UBLK_CONTROL_DEV "/dev/ublk-control" + +struct UblkDeviceOpts { + std::string image_config_path; // overlaybd image config (config.v1.json) + std::string service_config_path; // global service config, empty = default + // per-device log file; empty = shared log from the global service config. + // With one process per device, daemons sharing one log file interleave + // indistinguishable lines and race on rotation, so a dedicated path per + // device is recommended when running multiple devices. + std::string log_path; + int dev_id = -1; // -1: let the kernel allocate + int queue_depth = 128; +}; + +// Check /dev/ublk-control and report a clear error if unusable. +// Returns 0 on success, -1 (with message on stderr) otherwise. +int ublk_check_control_dev(); + +// Run one overlaybd-ublk device until SIGTERM/SIGINT (process == device). +// On readiness the block device path ("/dev/ublkbN\n") is +// written to ready_fd (the add command's sync-ready contract); ready_fd +// is closed afterwards unless it is stdout/stderr. Returns the process +// exit code. +int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd); From 5cddd360f14e9ecc36e768995a7bcf1b1d4196f2 Mon Sep 17 00:00:00 2001 From: haolianglh Date: Mon, 27 Jul 2026 23:07:57 +0800 Subject: [PATCH 2/8] Add per-device cache directory option to overlaybd-ublk With one process per device, daemons sharing one cache directory can race: the file cache's eviction (truncate+unlink) and refill locking is in-process only, so concurrent daemons risk accounting drift, cache thrashing, and a small window of serving stale zeroes when eviction truncates a file another daemon is reading. Introduce `add --cache-dir `, giving each device its own registry and gzip cache directories. The override is applied by pointing create_image_service at a patched temporary copy of the service config, leaving the shared image_service code untouched. Document that multiple devices must use separate cache directories. Signed-off-by: haolianglh --- README.md | 5 ++ src/ublk/CMakeLists.txt | 1 + src/ublk/cli.cpp | 4 ++ src/ublk/config_patch.cpp | 54 +++++++++++++++++++++ src/ublk/config_patch.h | 28 +++++++++++ src/ublk/test/CMakeLists.txt | 4 +- src/ublk/test/ublk_dispatch_test.cpp | 39 ++++++++++++++++ src/ublk/ublk_device.cpp | 70 +++++++++++++++++++++++++++- src/ublk/ublk_device.h | 5 ++ 9 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 src/ublk/config_patch.cpp create mode 100644 src/ublk/config_patch.h diff --git a/README.md b/README.md index 9eac84bb..c6240c47 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ Notes: * Always unmount filesystems on `/dev/ublkbN` before `del` (or before stopping the daemon): the daemon serves the device's IO, so tearing it down with a mounted filesystem aborts in-flight journal writes. +* When running multiple devices, give each daemon its own cache directory + (`add --cache-dir ...`): the file cache's eviction/refill locking is + in-process only, so daemons sharing one cache directory can race + (accounting drift, cache thrashing, and a small window of serving stale + zeroes). A per-device log file (`add --log-path ...`) is also recommended. * Runtime verified on a 5.10 kernel with ublk backported (Alinux 5.10.134); a full regression on mainline 6.x kernels is still pending. Known backport-kernel caveats: DISCARD is unavailable there, and `del` diff --git a/src/ublk/CMakeLists.txt b/src/ublk/CMakeLists.txt index abf3fc98..c33f32c6 100644 --- a/src/ublk/CMakeLists.txt +++ b/src/ublk/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(ublk_frontend_lib io_dispatch.cpp ublk_device.cpp cli.cpp + config_patch.cpp ) target_include_directories(ublk_frontend_lib PUBLIC ${PHOTON_INCLUDE_DIR} diff --git a/src/ublk/cli.cpp b/src/ublk/cli.cpp index 46526b1d..b6b5cc54 100644 --- a/src/ublk/cli.cpp +++ b/src/ublk/cli.cpp @@ -37,6 +37,10 @@ int ublk_parse_cli(int argc, char **argv, UblkCliCmd &cmd) { add->add_option("--log-path", cmd.opts.log_path, "per-device log file (default: shared log from service config; " "recommended when running multiple devices)"); + add->add_option("--cache-dir", cmd.opts.cache_dir, + "dedicated cache directory for this device (default: shared cache " + "dirs from service config; required when running multiple devices, " + "cache locking is in-process only)"); add->add_flag("--foreground", cmd.foreground, "run in foreground (no daemonize)"); auto *del = app.add_subcommand("del", "stop the daemon serving /dev/ublkbN"); diff --git a/src/ublk/config_patch.cpp b/src/ublk/config_patch.cpp new file mode 100644 index 00000000..ddd9feed --- /dev/null +++ b/src/ublk/config_patch.cpp @@ -0,0 +1,54 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#include "config_patch.h" + +#include +#include +#include + +int ublk_patch_cache_dirs(const std::string &base_json, const std::string &cache_dir, + std::string &out_json) { + rapidjson::Document doc; + doc.Parse(base_json.c_str()); + if (doc.HasParseError() || !doc.IsObject()) + return -1; + + auto &alloc = doc.GetAllocator(); + std::string registry_dir = cache_dir + "/registry_cache"; + std::string gzip_dir = cache_dir + "/gzip_cache"; + + auto set_str = [&](rapidjson::Value &obj, const char *key, const std::string &val) { + obj.RemoveMember(key); + obj.AddMember(rapidjson::Value(key, alloc), rapidjson::Value(val.c_str(), alloc), + alloc); + }; + + // new-style cache config (ImageService uses it when cacheType is set) + if (!doc.HasMember("cacheConfig")) + doc.AddMember("cacheConfig", rapidjson::Value(rapidjson::kObjectType), alloc); + set_str(doc["cacheConfig"], "cacheDir", registry_dir); + // legacy top-level field (used when cacheConfig.cacheType is empty) + set_str(doc, "registryCacheDir", registry_dir); + // gzip cache has its own directory; only patch if the section exists + if (doc.HasMember("gzipCacheConfig")) + set_str(doc["gzipCacheConfig"], "cacheDir", gzip_dir); + + rapidjson::StringBuffer sb; + rapidjson::Writer writer(sb); + doc.Accept(writer); + out_json.assign(sb.GetString(), sb.GetSize()); + return 0; +} diff --git a/src/ublk/config_patch.h b/src/ublk/config_patch.h new file mode 100644 index 00000000..1b953dac --- /dev/null +++ b/src/ublk/config_patch.h @@ -0,0 +1,28 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#pragma once + +#include + +// Rewrite every cache directory in a service config JSON to live under +// cache_dir (registry cache -> /registry_cache, gzip cache -> +// /gzip_cache). Pure string -> string transformation, kept apart +// from ublk_device.cpp so it is unit-testable without photon/ImageService. +// +// Returns 0 on success with the serialized result in out_json, -1 if +// base_json is not a valid JSON object. +int ublk_patch_cache_dirs(const std::string &base_json, const std::string &cache_dir, + std::string &out_json); diff --git a/src/ublk/test/CMakeLists.txt b/src/ublk/test/CMakeLists.txt index 80d6b727..f6d30a86 100644 --- a/src/ublk/test/CMakeLists.txt +++ b/src/ublk/test/CMakeLists.txt @@ -11,8 +11,10 @@ add_executable(ublk_dispatch_test ublk_dispatch_test.cpp ../io_dispatch.cpp ../cli.cpp + ../config_patch.cpp ) -target_include_directories(ublk_dispatch_test PUBLIC ${UBLKSRV_INCLUDE_DIRS} ${LIBURING_INCLUDE_DIRS}) +target_include_directories(ublk_dispatch_test PUBLIC + ${UBLKSRV_INCLUDE_DIRS} ${LIBURING_INCLUDE_DIRS} ${RAPIDJSON_INCLUDE_DIRS}) target_link_libraries(ublk_dispatch_test gtest gtest_main pthread) add_test( diff --git a/src/ublk/test/ublk_dispatch_test.cpp b/src/ublk/test/ublk_dispatch_test.cpp index 2717ef5c..0e3bd61e 100644 --- a/src/ublk/test/ublk_dispatch_test.cpp +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -14,6 +14,7 @@ limitations under the License. */ #include "../cli.h" +#include "../config_patch.h" #include "../io_dispatch.h" #include @@ -223,3 +224,41 @@ TEST(cli, list_and_missing_subcommand) { EXPECT_NE(parse({}, cmd2), 0); // subcommand is mandatory EXPECT_EQ(cmd2.kind, UblkCliCmd::Kind::NONE); } + +// --------------------------------------------------------------------------- +// config_patch: per-device cache dir rewriting (--cache-dir) +// --------------------------------------------------------------------------- + +TEST(config_patch, rewrites_all_cache_dirs) { + std::string base = R"({ + "logConfig": { "logLevel": 1 }, + "cacheConfig": { "cacheType": "file", "cacheDir": "/opt/overlaybd/registry_cache" }, + "gzipCacheConfig": { "enable": true, "cacheDir": "/opt/overlaybd/gzip_cache" } + })"; + std::string out; + ASSERT_EQ(ublk_patch_cache_dirs(base, "/var/cache/obd-dev0", out), 0); + EXPECT_NE(out.find("/var/cache/obd-dev0/registry_cache"), std::string::npos); + EXPECT_NE(out.find("/var/cache/obd-dev0/gzip_cache"), std::string::npos); + EXPECT_EQ(out.find("/opt/overlaybd/registry_cache"), std::string::npos); + EXPECT_EQ(out.find("/opt/overlaybd/gzip_cache"), std::string::npos); + // unrelated fields survive the rewrite + EXPECT_NE(out.find("\"cacheType\":\"file\""), std::string::npos); + EXPECT_NE(out.find("\"logLevel\":1"), std::string::npos); +} + +TEST(config_patch, legacy_config_without_cache_sections) { + std::string out; + ASSERT_EQ(ublk_patch_cache_dirs("{}", "/tmp/c", out), 0); + // both the legacy field and the new-style section are covered + EXPECT_NE(out.find("\"registryCacheDir\":\"/tmp/c/registry_cache\""), + std::string::npos); + EXPECT_NE(out.find("\"cacheConfig\""), std::string::npos); + EXPECT_EQ(out.find("gzipCacheConfig"), std::string::npos); // section not invented +} + +TEST(config_patch, rejects_invalid_json) { + std::string out; + EXPECT_NE(ublk_patch_cache_dirs("not json", "/tmp/c", out), 0); + EXPECT_NE(ublk_patch_cache_dirs("[1,2]", "/tmp/c", out), 0); // array, not object +} + diff --git a/src/ublk/ublk_device.cpp b/src/ublk/ublk_device.cpp index cf888402..2bd9515a 100644 --- a/src/ublk/ublk_device.cpp +++ b/src/ublk/ublk_device.cpp @@ -14,6 +14,7 @@ limitations under the License. */ #include "ublk_device.h" +#include "config_patch.h" #include "io_dispatch.h" #include "../image_file.h" @@ -33,6 +34,7 @@ #include #include #include +#include #include #include @@ -332,18 +334,84 @@ static void ctrl_del_and_deinit() { g_ctrl_dev = nullptr; } +// mkdir -p: ImageService's create_dir only does single-level mkdir, so a +// fresh --cache-dir path must be created (with subdirs) before the service +// initializes. +static int mkdir_p(const std::string &path, mode_t mode) { + for (size_t i = 1; i <= path.size(); i++) { + if (i == path.size() || path[i] == '/') { + std::string cur = path.substr(0, i); + if (mkdir(cur.c_str(), mode) != 0 && errno != EEXIST) + return -1; + } + } + return 0; +} + +// --cache-dir support: ImageService parses its cache directories deep inside +// init(), so the override is applied by writing a patched copy of the service +// config into the run dir and pointing create_image_service at it (keeps the +// shared image_service code untouched). The temp file is removed right after +// the service is created. +static int make_patched_service_config(const UblkDeviceOpts &opts, std::string &out_path) { + const char *base_path = opts.service_config_path.empty() + ? "/etc/overlaybd/overlaybd.json" // create_image_service default + : opts.service_config_path.c_str(); + std::ifstream in(base_path); + if (!in) { + fprintf(stderr, "overlaybd-ublk: cannot read service config %s\n", base_path); + return -1; + } + std::string base_json((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + + std::string patched; + if (ublk_patch_cache_dirs(base_json, opts.cache_dir, patched) != 0) { + fprintf(stderr, "overlaybd-ublk: service config %s is not a valid JSON object\n", + base_path); + return -1; + } + + char path[128]; + snprintf(path, sizeof(path), "%s/%d.service.json", OVERLAYBD_UBLK_RUN_DIR, + (int)getpid()); + std::ofstream out(path, std::ios::trunc); + if (!out || !(out << patched)) { + fprintf(stderr, "overlaybd-ublk: cannot write %s\n", path); + return -1; + } + out_path = path; + return 0; +} + int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { if (ublk_check_control_dev() != 0) return 1; mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); // pidfile dir for libublksrv + std::string service_config = opts.service_config_path; + std::string patched_config; // temp file, removed after service init + if (!opts.cache_dir.empty()) { + if (mkdir_p(opts.cache_dir + "/registry_cache", 0755) != 0 || + mkdir_p(opts.cache_dir + "/gzip_cache", 0755) != 0) { + fprintf(stderr, "overlaybd-ublk: cannot create cache dir %s: %s\n", + opts.cache_dir.c_str(), strerror(errno)); + return 1; + } + if (make_patched_service_config(opts, patched_config) != 0) + return 1; + service_config = patched_config; + } + photon::init(photon::INIT_EVENT_DEFAULT, photon::INIT_IO_DEFAULT); photon::block_all_signal(); photon::sync_signal(SIGTERM, &stop_device_handler); photon::sync_signal(SIGINT, &stop_device_handler); g_imgservice = create_image_service( - opts.service_config_path.empty() ? nullptr : opts.service_config_path.c_str()); + service_config.empty() ? nullptr : service_config.c_str()); + if (!patched_config.empty()) + unlink(patched_config.c_str()); // config parsed, temp copy no longer needed if (g_imgservice == nullptr) { LOG_ERROR("failed to create image service"); return 1; diff --git a/src/ublk/ublk_device.h b/src/ublk/ublk_device.h index 4bd7ac19..f72b3db4 100644 --- a/src/ublk/ublk_device.h +++ b/src/ublk/ublk_device.h @@ -30,6 +30,11 @@ struct UblkDeviceOpts { // indistinguishable lines and race on rotation, so a dedicated path per // device is recommended when running multiple devices. std::string log_path; + // dedicated cache directory for this device (registry/gzip caches land in + // subdirs); empty = shared dirs from the global service config. The file + // cache's eviction/refill locking is in-process only, so daemons sharing + // one cache directory can race -- REQUIRED when running multiple devices. + std::string cache_dir; int dev_id = -1; // -1: let the kernel allocate int queue_depth = 128; }; From db93a290cb6b4e2b801b7260dfc6b4649bfe5142 Mon Sep 17 00:00:00 2001 From: haolianglh Date: Tue, 28 Jul 2026 09:37:43 +0800 Subject: [PATCH 3/8] Isolate overlaybd-ublk caches per image instance The file cache's eviction and refill locking is in-process only, so two daemons sharing a cache directory can corrupt space accounting, thrash each other's evictions, and in a small window serve stale zeroes. With one process per device, the unsafe configuration must not be reachable by default. Give every device its own cache directory, always: ///{registry_cache,gzip_cache} * defaults to /opt/overlaybd/ublk_cache; --cache-dir overrides the base only * is a hash of the image config's realpath, so remounting an image reuses its warm cache and distinct images never collide; a "source" file records the origin path * is claimed via flock (inst0, inst1, ...): mounting the same read-only image repeatedly is a legitimate pattern and gets automatically numbered instances, or a caller-pinned name via --instance-id * writable images (a config with an effective upper) are restricted to a single exclusive mount: concurrent RW mounts would corrupt the upper layer itself, not just the cache Also fold the six file-scope globals of ublk_device.cpp into a UblkDevice class (libublksrv callbacks reach it through the ctrl-dev priv-data slot), removing the last single-device assumption from the code in preparation for a possible multi-device daemon mode. Unit tests cover the config patching, image key derivation, writable detection and the CLI additions (19 cases); runtime verified on a ublk-capable kernel: automatic slots, writable-mount rejection, --instance-id, warm directory reuse, and the full add/IO/del lifecycle. Signed-off-by: haolianglh --- README.md | 14 +- src/ublk/cli.cpp | 9 +- src/ublk/config_patch.cpp | 34 +++ src/ublk/config_patch.h | 12 + src/ublk/test/ublk_dispatch_test.cpp | 34 +++ src/ublk/ublk_device.cpp | 322 ++++++++++++++++++++------- src/ublk/ublk_device.h | 12 +- 7 files changed, 348 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index c6240c47..59a79e1b 100644 --- a/README.md +++ b/README.md @@ -150,11 +150,15 @@ Notes: * Always unmount filesystems on `/dev/ublkbN` before `del` (or before stopping the daemon): the daemon serves the device's IO, so tearing it down with a mounted filesystem aborts in-flight journal writes. -* When running multiple devices, give each daemon its own cache directory - (`add --cache-dir ...`): the file cache's eviction/refill locking is - in-process only, so daemons sharing one cache directory can race - (accounting drift, cache thrashing, and a small window of serving stale - zeroes). A per-device log file (`add --log-path ...`) is also recommended. +* Caches are always isolated per device: each daemon uses + `/opt/overlaybd/ublk_cache///` (override the base + directory with `add --cache-dir ...`), guarded by an exclusive lock -- the + file cache's locking is in-process only, so daemons must never share a + cache directory. Mounting the same read-only image multiple times gets + automatically numbered instances (or pin one with `--instance-id`); + remounting an image reuses its warm cache; mounting a writable image twice + is rejected to protect its upper layer. A per-device log file + (`add --log-path ...`) is recommended when running multiple devices. * Runtime verified on a 5.10 kernel with ublk backported (Alinux 5.10.134); a full regression on mainline 6.x kernels is still pending. Known backport-kernel caveats: DISCARD is unavailable there, and `del` diff --git a/src/ublk/cli.cpp b/src/ublk/cli.cpp index b6b5cc54..193d6545 100644 --- a/src/ublk/cli.cpp +++ b/src/ublk/cli.cpp @@ -38,9 +38,12 @@ int ublk_parse_cli(int argc, char **argv, UblkCliCmd &cmd) { "per-device log file (default: shared log from service config; " "recommended when running multiple devices)"); add->add_option("--cache-dir", cmd.opts.cache_dir, - "dedicated cache directory for this device (default: shared cache " - "dirs from service config; required when running multiple devices, " - "cache locking is in-process only)"); + "base directory for the per-device cache (default " + "/opt/overlaybd/ublk_cache); caches are always isolated per " + "image under ///"); + add->add_option("--instance-id", cmd.opts.instance_id, + "explicit cache instance name for mounting the same read-only " + "image multiple times (default: auto slot instN)"); add->add_flag("--foreground", cmd.foreground, "run in foreground (no daemonize)"); auto *del = app.add_subcommand("del", "stop the daemon serving /dev/ublkbN"); diff --git a/src/ublk/config_patch.cpp b/src/ublk/config_patch.cpp index ddd9feed..91e12ae0 100644 --- a/src/ublk/config_patch.cpp +++ b/src/ublk/config_patch.cpp @@ -15,10 +15,44 @@ */ #include "config_patch.h" +#include +#include + #include #include #include +std::string ublk_image_cache_key(const std::string &canonical_path) { + // FNV-1a 64-bit: tiny, dependency-free, stable across runs and platforms; + // collision odds are negligible for config paths on a single host + uint64_t h = 0xcbf29ce484222325ULL; + for (unsigned char c : canonical_path) { + h ^= c; + h *= 0x100000001b3ULL; + } + char buf[17]; + snprintf(buf, sizeof(buf), "%016llx", (unsigned long long)h); + return buf; +} + +bool ublk_config_has_upper(const std::string &image_json) { + rapidjson::Document doc; + doc.Parse(image_json.c_str()); + if (doc.HasParseError() || !doc.IsObject() || !doc.HasMember("upper")) + return false; + const auto &upper = doc["upper"]; + if (!upper.IsObject()) + return false; + // mirrors init_image_file(): an upper is effective when any of its + // layer paths is set (index/data for LSMT, target for turboOCI) + for (const char *key : {"index", "data", "target"}) { + if (upper.HasMember(key) && upper[key].IsString() && + upper[key].GetStringLength() > 0) + return true; + } + return false; +} + int ublk_patch_cache_dirs(const std::string &base_json, const std::string &cache_dir, std::string &out_json) { rapidjson::Document doc; diff --git a/src/ublk/config_patch.h b/src/ublk/config_patch.h index 1b953dac..0ef09337 100644 --- a/src/ublk/config_patch.h +++ b/src/ublk/config_patch.h @@ -26,3 +26,15 @@ // base_json is not a valid JSON object. int ublk_patch_cache_dirs(const std::string &base_json, const std::string &cache_dir, std::string &out_json); + +// Derive the per-image cache subdirectory name from the canonicalized +// (realpath'd) image config path: 16 hex chars of FNV-1a 64. Cache identity +// follows the image, so remounting the same image hits a warm cache while +// different images can never share a directory. +std::string ublk_image_cache_key(const std::string &canonical_path); + +// Whether an image config (config.v1.json content) has a writable upper +// layer. Writable images must be mounted exclusively: concurrent mounts +// would corrupt the upper layer, so they are restricted to cache instance +// slot 0 (read-only images may claim further slots concurrently). +bool ublk_config_has_upper(const std::string &image_json); diff --git a/src/ublk/test/ublk_dispatch_test.cpp b/src/ublk/test/ublk_dispatch_test.cpp index 0e3bd61e..4ab228b2 100644 --- a/src/ublk/test/ublk_dispatch_test.cpp +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -204,6 +204,17 @@ TEST(cli, add_log_path) { EXPECT_EQ(cmd.opts.log_path, "/var/log/obd-ublk0.log"); } +TEST(cli, add_instance_id) { + UblkCliCmd cmd; + char self[] = "/proc/self/exe"; + ASSERT_EQ(parse({"add", "--config", self, "--instance-id", "snap-42"}, cmd), 0); + EXPECT_EQ(cmd.opts.instance_id, "snap-42"); + + UblkCliCmd cmd2; + ASSERT_EQ(parse({"add", "--config", self}, cmd2), 0); + EXPECT_TRUE(cmd2.opts.instance_id.empty()); // default: auto slot +} + TEST(cli, del_requires_dev_id) { UblkCliCmd cmd; EXPECT_NE(parse({"del"}, cmd), 0); @@ -262,3 +273,26 @@ TEST(config_patch, rejects_invalid_json) { EXPECT_NE(ublk_patch_cache_dirs("[1,2]", "/tmp/c", out), 0); // array, not object } +TEST(config_patch, image_cache_key_is_stable_hex16) { + auto k1 = ublk_image_cache_key("/tmp/a/config.v1.json"); + auto k2 = ublk_image_cache_key("/tmp/a/config.v1.json"); + auto k3 = ublk_image_cache_key("/tmp/b/config.v1.json"); + EXPECT_EQ(k1, k2); // deterministic: same image -> same (warm) cache dir + EXPECT_NE(k1, k3); // different images never share a dir + EXPECT_EQ(k1.size(), 16u); + EXPECT_EQ(k1.find_first_not_of("0123456789abcdef"), std::string::npos); +} + +TEST(config_patch, detects_writable_upper) { + // LSMT upper -> writable, must be mounted exclusively + EXPECT_TRUE(ublk_config_has_upper( + R"({"lowers":[],"upper":{"index":"/x/idx","data":"/x/data"}})")); + // turboOCI-style upper + EXPECT_TRUE(ublk_config_has_upper(R"({"upper":{"target":"/x/tgt"}})")); + // read-only: lowers only / empty or pathless upper / invalid json + EXPECT_FALSE(ublk_config_has_upper(R"({"lowers":[{"file":"/x/l0"}]})")); + EXPECT_FALSE(ublk_config_has_upper(R"({"upper":{}})")); + EXPECT_FALSE(ublk_config_has_upper(R"({"upper":{"index":""}})")); + EXPECT_FALSE(ublk_config_has_upper("not json")); +} + diff --git a/src/ublk/ublk_device.cpp b/src/ublk/ublk_device.cpp index 2bd9515a..13fc7196 100644 --- a/src/ublk/ublk_device.cpp +++ b/src/ublk/ublk_device.cpp @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -38,16 +39,12 @@ #include #include +#include #include +#include +#include #include -// Single device per process (the frontend's core design decision), so -// file-scope state is fine, mirroring the tcmu frontend's main.cpp style. -static ImageService *g_imgservice = nullptr; -static ImageFile *g_file = nullptr; -static struct ublksrv_ctrl_dev *g_ctrl_dev = nullptr; -static std::atomic g_queue_exited{false}; - int ublk_check_control_dev() { if (access(UBLK_CONTROL_DEV, F_OK) == 0) return 0; @@ -105,7 +102,44 @@ class ImageFileTarget : public UblkIOTarget { ImageFile *m_file; }; -static ImageFileTarget *g_target = nullptr; +// --------------------------------------------------------------------------- +// UblkDevice: all state of one device, formerly file-scope globals. +// One instance == one device; a process may in principle host several +// instances, which is what the daemon mode (ADR-0005) will build on. +// --------------------------------------------------------------------------- + +class UblkDevice { +public: + // Run the device until stop() is called (usually from a signal handler). + // Returns the process exit code. + int run(const UblkDeviceOpts &opts, int ready_fd); + + // Ask the device to stop; safe to call from a photon signal coroutine. + void stop() { + if (ctrl_dev_ != nullptr) + ublksrv_ctrl_stop_dev(ctrl_dev_); + } + + uint64_t image_size() const { + return file_->num_lbas * (uint64_t)file_->block_size; + } + +private: + int setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_root); + int claim_instance(const std::string &image_root, const std::string &inst, + std::string &cache_root); + void set_dev_parameters(); + void ctrl_del_and_deinit(); + void queue_loop(const struct ublksrv_dev *dev, photon::semaphore *started, + int *init_ret); + + ImageService *imgservice_ = nullptr; + ImageFile *file_ = nullptr; + ImageFileTarget *target_ = nullptr; + struct ublksrv_ctrl_dev *ctrl_dev_ = nullptr; + std::atomic queue_exited_{false}; + int cache_lock_fd_ = -1; // held for the device's lifetime, released on exit +}; // --------------------------------------------------------------------------- // data plane: per-queue photon-owned hybrid event loop @@ -114,6 +148,7 @@ static ImageFileTarget *g_target = nullptr; struct QueueCtx { const struct ublksrv_queue *q = nullptr; photon::ThreadPool<32> *pool = nullptr; + UblkIOTarget *target = nullptr; uint32_t inflight = 0; }; @@ -127,7 +162,7 @@ static void *io_worker(void *arg) { auto *ctx = (QueueCtx *)job->q->private_data; const struct ublksrv_io_desc *iod = job->data->iod; - int res = ublk_dispatch_io(g_target, ublksrv_get_op(iod), iod->start_sector << 9, + int res = ublk_dispatch_io(ctx->target, ublksrv_get_op(iod), iod->start_sector << 9, iod->nr_sectors << 9, ublksrv_queue_get_io_buf(job->q, job->data->tag)); @@ -150,9 +185,11 @@ static int obd_handle_io_async(const struct ublksrv_queue *q, const struct ublk_ } static int obd_init_tgt(struct ublksrv_dev *dev, int type, int argc, char *argv[]) { - const struct ublksrv_ctrl_dev_info *info = - ublksrv_ctrl_get_dev_info(ublksrv_get_ctrl_dev(dev)); - dev->tgt.dev_size = g_file->num_lbas * g_file->block_size; + const struct ublksrv_ctrl_dev *cdev = ublksrv_get_ctrl_dev(dev); + const struct ublksrv_ctrl_dev_info *info = ublksrv_ctrl_get_dev_info(cdev); + // the owning UblkDevice travels through the official priv-data slot + auto *device = (UblkDevice *)ublksrv_ctrl_get_priv_data(cdev); + dev->tgt.dev_size = device->image_size(); dev->tgt.tgt_ring_depth = info->queue_depth; dev->tgt.nr_fds = 0; return 0; @@ -174,7 +211,7 @@ static struct ublksrv_tgt_type obd_tgt_type = { // Runtime-verified on a 5.10 kernel with ublk backported (startup sequence, // idle wakeup, full-queue fio pressure); a regression pass on mainline 6.x // kernels is still pending. -static void queue_thread_fn(const struct ublksrv_dev *dev, photon::semaphore *started, +void UblkDevice::queue_loop(const struct ublksrv_dev *dev, photon::semaphore *started, int *init_ret) { photon::init(photon::INIT_EVENT_EPOLL, photon::INIT_IO_LIBCURL); DEFER(photon::fini()); @@ -182,6 +219,7 @@ static void queue_thread_fn(const struct ublksrv_dev *dev, photon::semaphore *st photon::ThreadPool<32> pool; QueueCtx ctx; ctx.pool = &pool; + ctx.target = target_; // IORING_SETUP_COOP_TASKRUN (mainline 5.19+) is a pure optimization, but // ublksrv_queue_init() hardcodes it. Backport kernels shipping ublk_drv on @@ -196,7 +234,7 @@ static void queue_thread_fn(const struct ublksrv_dev *dev, photon::semaphore *st if (q == nullptr) { LOG_ERROR("ublksrv_queue_init failed (see syslog for libublksrv details)"); *init_ret = -1; - g_queue_exited.store(true); + queue_exited_.store(true); started->signal(1); return; } @@ -239,16 +277,16 @@ static void queue_thread_fn(const struct ublksrv_dev *dev, photon::semaphore *st LOG_INFO("ublk queue exited"); ublksrv_queue_deinit(q); - g_queue_exited.store(true); + queue_exited_.store(true); } // --------------------------------------------------------------------------- // control plane: ctrl dev lifecycle, owned by the main thread // --------------------------------------------------------------------------- -static void set_dev_parameters(struct ublksrv_ctrl_dev *cdev, ImageFile *file) { - const struct ublksrv_ctrl_dev_info *info = ublksrv_ctrl_get_dev_info(cdev); - uint8_t bs_shift = (uint8_t)__builtin_ctz(file->block_size); +void UblkDevice::set_dev_parameters() { + const struct ublksrv_ctrl_dev_info *info = ublksrv_ctrl_get_dev_info(ctrl_dev_); + uint8_t bs_shift = (uint8_t)__builtin_ctz(file_->block_size); struct ublk_params p; memset(&p, 0, sizeof(p)); @@ -258,7 +296,7 @@ static void set_dev_parameters(struct ublksrv_ctrl_dev *cdev, ImageFile *file) { // volatile to flush, and advertising a cache would make the kernel send // FLUSH that fdatasync() on a RO ImageFile fails with (dmesg: // "lost sync page write"), so declare no cache for them - if (file->read_only) + if (file_->read_only) p.basic.attrs = UBLK_ATTR_READ_ONLY; else p.basic.attrs = UBLK_ATTR_VOLATILE_CACHE; @@ -267,24 +305,18 @@ static void set_dev_parameters(struct ublksrv_ctrl_dev *cdev, ImageFile *file) { p.basic.io_opt_shift = 12; p.basic.io_min_shift = bs_shift; p.basic.max_sectors = info->max_io_buf_bytes >> 9; - p.basic.dev_sectors = (file->num_lbas * file->block_size) >> 9; + p.basic.dev_sectors = image_size() >> 9; // TCMU equivalent: tcmu_dev_set_unmap_enabled(true) - p.discard.discard_granularity = file->block_size; + p.discard.discard_granularity = file_->block_size; p.discard.max_discard_sectors = info->max_io_buf_bytes >> 9; p.discard.max_write_zeroes_sectors = info->max_io_buf_bytes >> 9; p.discard.max_discard_segments = 1; - int ret = ublksrv_ctrl_set_params(cdev, &p); + int ret = ublksrv_ctrl_set_params(ctrl_dev_, &p); if (ret) LOG_ERROR("ublk dev ` set params failed: `", info->dev_id, ret); } -static void stop_device_handler(int signal) { - LOG_INFO("signal ` received, stopping ublk device", signal); - if (g_ctrl_dev != nullptr) - ublksrv_ctrl_stop_dev(g_ctrl_dev); -} - static void notify_ready(int ready_fd, int dev_id) { if (ready_fd < 0) return; @@ -306,12 +338,12 @@ static void notify_ready(int ready_fd, int dev_id) { // exit -- process teardown drops the references and the kernel completes // the pending deletion (verified: a hung DEL finished exactly at process // death and the dev id was reclaimed). -static void ctrl_del_and_deinit() { - int ret = ublksrv_ctrl_del_dev_async(g_ctrl_dev); +void UblkDevice::ctrl_del_and_deinit() { + int ret = ublksrv_ctrl_del_dev_async(ctrl_dev_); if (ret < 0) { LOG_WARN("DEL_DEV_ASYNC unavailable (`), falling back to bounded sync del", ret); auto *done = new std::atomic(false); - struct ublksrv_ctrl_dev *cdev = g_ctrl_dev; + struct ublksrv_ctrl_dev *cdev = ctrl_dev_; std::thread del_thread([done, cdev] { ublksrv_ctrl_del_dev(cdev); done->store(true); @@ -324,14 +356,14 @@ static void ctrl_del_and_deinit() { del_thread.detach(); // deliberately leak `done` and skip ctrl_deinit: the ctrl ring is // still in use by the blocked command, process exit reclaims both - g_ctrl_dev = nullptr; + ctrl_dev_ = nullptr; return; } del_thread.join(); delete done; } - ublksrv_ctrl_deinit(g_ctrl_dev); - g_ctrl_dev = nullptr; + ublksrv_ctrl_deinit(ctrl_dev_); + ctrl_dev_ = nullptr; } // mkdir -p: ImageService's create_dir only does single-level mkdir, so a @@ -353,7 +385,9 @@ static int mkdir_p(const std::string &path, mode_t mode) { // config into the run dir and pointing create_image_service at it (keeps the // shared image_service code untouched). The temp file is removed right after // the service is created. -static int make_patched_service_config(const UblkDeviceOpts &opts, std::string &out_path) { +static int make_patched_service_config(const UblkDeviceOpts &opts, + const std::string &cache_root, + std::string &out_path) { const char *base_path = opts.service_config_path.empty() ? "/etc/overlaybd/overlaybd.json" // create_image_service default : opts.service_config_path.c_str(); @@ -366,7 +400,7 @@ static int make_patched_service_config(const UblkDeviceOpts &opts, std::string & std::istreambuf_iterator()); std::string patched; - if (ublk_patch_cache_dirs(base_json, opts.cache_dir, patched) != 0) { + if (ublk_patch_cache_dirs(base_json, cache_root, patched) != 0) { fprintf(stderr, "overlaybd-ublk: service config %s is not a valid JSON object\n", base_path); return -1; @@ -384,35 +418,136 @@ static int make_patched_service_config(const UblkDeviceOpts &opts, std::string & return 0; } -int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { +// Cache isolation is mandatory: the file cache's eviction/refill locking is +// in-process only, so two daemons sharing a directory can corrupt accounting +// or serve stale zeroes. Layout: ///, where the +// image key follows the image identity (realpath of its config) so the same +// image reuses a warm cache, and the instance is claimed via flock -- the +// same read-only image mounted N times gets N isolated instances (inst0, +// inst1, ...), while writable images are restricted to inst0 (concurrent RW +// mounts would corrupt the upper layer). + +static bool valid_instance_id(const std::string &s) { + if (s.empty() || s.size() > 64) + return false; + for (char c : s) + if (!isalnum((unsigned char)c) && c != '.' && c != '_' && c != '-') + return false; + return true; +} + +// claim /: 0 = claimed, -2 = busy, -1 = hard error +int UblkDevice::claim_instance(const std::string &image_root, const std::string &inst, + std::string &cache_root) { + std::string root = image_root + "/" + inst; + if (mkdir_p(root + "/registry_cache", 0755) != 0 || + mkdir_p(root + "/gzip_cache", 0755) != 0) { + fprintf(stderr, "overlaybd-ublk: cannot create cache dir %s: %s\n", root.c_str(), + strerror(errno)); + return -1; + } + std::string lock_path = root + "/.lock"; + int fd = open(lock_path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644); + if (fd < 0) { + fprintf(stderr, "overlaybd-ublk: cannot open %s: %s\n", lock_path.c_str(), + strerror(errno)); + return -1; + } + // held for the daemon's lifetime; the kernel releases it on any kind of + // process exit, so there is no stale-lock handling to do + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + close(fd); + return -2; + } + cache_lock_fd_ = fd; + cache_root = root; + return 0; +} + +int UblkDevice::setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_root) { + char resolved[PATH_MAX]; + if (realpath(opts.image_config_path.c_str(), resolved) == nullptr) { + fprintf(stderr, "overlaybd-ublk: realpath(%s) failed: %s\n", + opts.image_config_path.c_str(), strerror(errno)); + return -1; + } + + std::ifstream img_in(resolved); + std::string image_json((std::istreambuf_iterator(img_in)), + std::istreambuf_iterator()); + bool writable = ublk_config_has_upper(image_json); + + std::string base = + opts.cache_dir.empty() ? "/opt/overlaybd/ublk_cache" : opts.cache_dir; + std::string image_root = base + "/" + ublk_image_cache_key(resolved); + if (mkdir_p(image_root, 0755) != 0) { + fprintf(stderr, "overlaybd-ublk: cannot create %s: %s\n", image_root.c_str(), + strerror(errno)); + return -1; + } + // best effort: record which image this cache belongs to + std::ofstream src(image_root + "/source", std::ios::trunc); + if (src) + src << resolved << "\n"; + src.close(); + + if (!opts.instance_id.empty()) { + if (writable) { + fprintf(stderr, "overlaybd-ublk: --instance-id is not allowed for a " + "writable image (it can only be mounted once)\n"); + return -1; + } + if (!valid_instance_id(opts.instance_id)) { + fprintf(stderr, "overlaybd-ublk: invalid --instance-id (allowed: " + "[A-Za-z0-9._-], max 64 chars)\n"); + return -1; + } + int r = claim_instance(image_root, opts.instance_id, cache_root); + if (r == -2) + fprintf(stderr, "overlaybd-ublk: instance '%s' of this image is " + "already running\n", + opts.instance_id.c_str()); + return r == 0 ? 0 : -1; + } + + if (writable) { + int r = claim_instance(image_root, "inst0", cache_root); + if (r == -2) + fprintf(stderr, "overlaybd-ublk: writable image already mounted " + "(concurrent RW mounts would corrupt the upper layer)\n"); + return r == 0 ? 0 : -1; + } + + for (int i = 0; i < 64; i++) { + int r = claim_instance(image_root, "inst" + std::to_string(i), cache_root); + if (r == 0) + return 0; + if (r == -1) + return -1; + } + fprintf(stderr, "overlaybd-ublk: all 64 cache instance slots of this image " + "are busy\n"); + return -1; +} + +int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { if (ublk_check_control_dev() != 0) return 1; mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); // pidfile dir for libublksrv - std::string service_config = opts.service_config_path; + std::string cache_root; + if (setup_cache_root(opts, cache_root) != 0) + return 1; std::string patched_config; // temp file, removed after service init - if (!opts.cache_dir.empty()) { - if (mkdir_p(opts.cache_dir + "/registry_cache", 0755) != 0 || - mkdir_p(opts.cache_dir + "/gzip_cache", 0755) != 0) { - fprintf(stderr, "overlaybd-ublk: cannot create cache dir %s: %s\n", - opts.cache_dir.c_str(), strerror(errno)); - return 1; - } - if (make_patched_service_config(opts, patched_config) != 0) - return 1; - service_config = patched_config; - } - - photon::init(photon::INIT_EVENT_DEFAULT, photon::INIT_IO_DEFAULT); - photon::block_all_signal(); - photon::sync_signal(SIGTERM, &stop_device_handler); - photon::sync_signal(SIGINT, &stop_device_handler); + if (make_patched_service_config(opts, cache_root, patched_config) != 0) + return 1; + std::string service_config = patched_config; - g_imgservice = create_image_service( + imgservice_ = create_image_service( service_config.empty() ? nullptr : service_config.c_str()); if (!patched_config.empty()) unlink(patched_config.c_str()); // config parsed, temp copy no longer needed - if (g_imgservice == nullptr) { + if (imgservice_ == nullptr) { LOG_ERROR("failed to create image service"); return 1; } @@ -420,9 +555,9 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { // shared logPath from the global config); reuse the global size/rotation if (!opts.log_path.empty()) { // APPCFG defaults (10MB x 3) also cover old-style configs without logConfig - uint64_t log_size = 1024UL * 1024 * g_imgservice->global_conf.logConfig().logSizeMB(); + uint64_t log_size = 1024UL * 1024 * imgservice_->global_conf.logConfig().logSizeMB(); int ret = log_output_file(opts.log_path.c_str(), log_size, - g_imgservice->global_conf.logConfig().logRotateNum()); + imgservice_->global_conf.logConfig().logRotateNum()); if (ret != 0) LOG_ERROR("failed to set per-device log path `, keep shared log", opts.log_path.c_str()); @@ -433,12 +568,12 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { std::string dev_tag = "ublk-" + std::to_string(getpid()); // open the image before anything ublk-related: dev_size is needed by // init_tgt, and a broken config must fail the add command, not the device - g_file = g_imgservice->create_image_file(opts.image_config_path.c_str(), dev_tag); - if (g_file == nullptr) { + file_ = imgservice_->create_image_file(opts.image_config_path.c_str(), dev_tag); + if (file_ == nullptr) { LOG_ERROR("failed to open image `", opts.image_config_path.c_str()); return 1; } - g_target = new ImageFileTarget(g_file); + target_ = new ImageFileTarget(file_); struct ublksrv_dev_data data; memset(&data, 0, sizeof(data)); @@ -451,26 +586,29 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { data.run_dir = OVERLAYBD_UBLK_RUN_DIR; data.flags = 0; - g_ctrl_dev = ublksrv_ctrl_init(&data); - if (g_ctrl_dev == nullptr) { + ctrl_dev_ = ublksrv_ctrl_init(&data); + if (ctrl_dev_ == nullptr) { LOG_ERROR("ublksrv_ctrl_init failed"); return 1; } + // route the owning device into libublksrv callbacks (obd_init_tgt) + ublksrv_ctrl_set_priv_data(ctrl_dev_, this); - int ret = ublksrv_ctrl_add_dev(g_ctrl_dev); + int ret = ublksrv_ctrl_add_dev(ctrl_dev_); if (ret < 0) { LOG_ERROR("ublksrv_ctrl_add_dev failed: `", ret); - ublksrv_ctrl_deinit(g_ctrl_dev); + ublksrv_ctrl_deinit(ctrl_dev_); + ctrl_dev_ = nullptr; return 1; } - int dev_id = ublksrv_ctrl_get_dev_info(g_ctrl_dev)->dev_id; + int dev_id = ublksrv_ctrl_get_dev_info(ctrl_dev_)->dev_id; - ret = ublksrv_ctrl_get_affinity(g_ctrl_dev); + ret = ublksrv_ctrl_get_affinity(ctrl_dev_); if (ret < 0) LOG_WARN("ublksrv_ctrl_get_affinity failed: `", ret); { - const struct ublksrv_dev *dev = ublksrv_dev_init(g_ctrl_dev); + const struct ublksrv_dev *dev = ublksrv_dev_init(ctrl_dev_); if (dev == nullptr) { LOG_ERROR("ublksrv_dev_init failed"); goto fail_del_dev; @@ -478,7 +616,8 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { photon::semaphore queue_started; int queue_init_ret = -1; - std::thread queue_thread(queue_thread_fn, dev, &queue_started, &queue_init_ret); + std::thread queue_thread(&UblkDevice::queue_loop, this, dev, &queue_started, + &queue_init_ret); queue_started.wait(1); if (queue_init_ret != 0) { queue_thread.join(); @@ -486,13 +625,13 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { goto fail_del_dev; } - set_dev_parameters(g_ctrl_dev, g_file); + set_dev_parameters(); - ret = ublksrv_ctrl_start_dev(g_ctrl_dev, getpid()); + ret = ublksrv_ctrl_start_dev(ctrl_dev_, getpid()); if (ret < 0) { LOG_ERROR("ublksrv_ctrl_start_dev failed: `", ret); - ublksrv_ctrl_stop_dev(g_ctrl_dev); // unblock the queue thread - while (!g_queue_exited.load()) + ublksrv_ctrl_stop_dev(ctrl_dev_); // unblock the queue thread + while (!queue_exited_.load()) photon::thread_usleep(10 * 1000); queue_thread.join(); ublksrv_dev_deinit(dev); @@ -505,7 +644,7 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { // photon-sleep instead of a bare join: sync_signal handlers run as // photon coroutines in this thread and must keep getting scheduled - while (!g_queue_exited.load()) + while (!queue_exited_.load()) photon::thread_usleep(200 * 1000); queue_thread.join(); ublksrv_dev_deinit(dev); @@ -513,9 +652,9 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { ctrl_del_and_deinit(); - delete g_target; - delete g_file; - delete g_imgservice; + delete target_; + delete file_; + delete imgservice_; LOG_INFO("overlaybd-ublk exited"); return 0; @@ -523,3 +662,32 @@ int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { ctrl_del_and_deinit(); return 1; } + +// --------------------------------------------------------------------------- +// standalone-binary entry layer +// --------------------------------------------------------------------------- + +// Signal routing for the one-device-per-process binary: POSIX handlers can't +// carry a closure, so the current device is parked in a file-scope pointer. +// This is entry-layer plumbing, not device state -- a future daemon +// (ADR-0005) installs its own handler that walks its device table instead. +static UblkDevice *g_signal_device = nullptr; + +static void stop_device_handler(int signal) { + LOG_INFO("signal ` received, stopping ublk device", signal); + if (g_signal_device != nullptr) + g_signal_device->stop(); +} + +int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd) { + photon::init(photon::INIT_EVENT_DEFAULT, photon::INIT_IO_DEFAULT); + photon::block_all_signal(); + photon::sync_signal(SIGTERM, &stop_device_handler); + photon::sync_signal(SIGINT, &stop_device_handler); + + UblkDevice device; + g_signal_device = &device; + int ret = device.run(opts, ready_fd); + g_signal_device = nullptr; + return ret; +} diff --git a/src/ublk/ublk_device.h b/src/ublk/ublk_device.h index f72b3db4..03d13c61 100644 --- a/src/ublk/ublk_device.h +++ b/src/ublk/ublk_device.h @@ -30,11 +30,15 @@ struct UblkDeviceOpts { // indistinguishable lines and race on rotation, so a dedicated path per // device is recommended when running multiple devices. std::string log_path; - // dedicated cache directory for this device (registry/gzip caches land in - // subdirs); empty = shared dirs from the global service config. The file - // cache's eviction/refill locking is in-process only, so daemons sharing - // one cache directory can race -- REQUIRED when running multiple devices. + // base directory for this device's cache; empty = /opt/overlaybd/ublk_cache. + // Cache isolation is always on: caches live in /// + // (key = hash of the image config's realpath) guarded by an exclusive + // flock, because the file cache's locking is in-process only. std::string cache_dir; + // explicit cache instance name; empty = auto slot (inst0, inst1, ...). + // Lets callers with a stable identity (e.g. a future snapshotter) pin + // an instance; only meaningful for read-only images. + std::string instance_id; int dev_id = -1; // -1: let the kernel allocate int queue_depth = 128; }; From a180731e48194c87c086e48e282a89fe503d207d Mon Sep 17 00:00:00 2001 From: haolianglh Date: Tue, 28 Jul 2026 14:09:25 +0800 Subject: [PATCH 4/8] Add overlaybd-ublkd daemon managing many devices Add a daemon that hosts all ublk devices in one process and shares a single ImageService, complementing the one-process-per-device CLI. The control plane is HTTP over a root-only unix socket (docker.sock style, curl-debuggable): POST /v1/add and /v1/del, GET /v1/list and /v1/ping, POST /v1/shutdown. add replies only when the device is usable; del replies after the device is fully torn down; SIGTERM or shutdown stops all devices in parallel (STOP first, then join) so total teardown time stays close to the slowest single device. To host several devices per process, split UblkDevice::run() into start()/wait()/stop()/teardown() with a second construction path that injects a shared ImageService, and derive the ImageService registration tag from pid plus a sequence number (a bare pid tag collides on the second add). Also fix a latent fd-0 bug the daemon exposed: on kernels without IORING_SETUP_COOP_TASKRUN, the old init-then-retry flow made every queue creation fail once, and libublksrv's failure path closes fd 0 (ublksrv_queue_deinit runs on a zero-initialized queue whose epollfd/efd are 0 behind >= 0 guards). With one process per device the victim was stdin and the retried ring landed on fd 0, working by luck; in the daemon, fd 0 was the previous device's queue ring, so every add silently wedged the device added before it. Probe the flag once at startup with a private throwaway ring instead, so the failing path is never reached; the daemon additionally parks fd 0 on /dev/null as a guard. Protocol parsing/encoding is a standalone photon-free module with unit tests (23 cases in total). Verified on a ublk-capable kernel: mixed RO/RW devices, cross-device regression probes, concurrent dual-device fio with data verification, del, and parallel shutdown. Signed-off-by: haolianglh --- src/ublk/CMakeLists.txt | 15 ++ src/ublk/test/CMakeLists.txt | 1 + src/ublk/test/ublk_dispatch_test.cpp | 62 ++++++ src/ublk/ublk_device.cpp | 274 +++++++++++++++++---------- src/ublk/ublk_device.h | 72 +++++++ src/ublk/ublkd_main.cpp | 262 +++++++++++++++++++++++++ src/ublk/ublkd_protocol.cpp | 131 +++++++++++++ src/ublk/ublkd_protocol.h | 53 ++++++ 8 files changed, 766 insertions(+), 104 deletions(-) create mode 100644 src/ublk/ublkd_main.cpp create mode 100644 src/ublk/ublkd_protocol.cpp create mode 100644 src/ublk/ublkd_protocol.h diff --git a/src/ublk/CMakeLists.txt b/src/ublk/CMakeLists.txt index c33f32c6..3ad6e9ce 100644 --- a/src/ublk/CMakeLists.txt +++ b/src/ublk/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(ublk_frontend_lib ublk_device.cpp cli.cpp config_patch.cpp + ublkd_protocol.cpp ) target_include_directories(ublk_frontend_lib PUBLIC ${PHOTON_INCLUDE_DIR} @@ -31,6 +32,20 @@ target_link_libraries(overlaybd-ublk install(TARGETS overlaybd-ublk DESTINATION /opt/overlaybd/bin) +# overlaybd-ublkd: daemon mode, all devices in one process (ADR-0006) +add_executable(overlaybd-ublkd ublkd_main.cpp) +target_link_libraries(overlaybd-ublkd + ublk_frontend_lib + photon_static + overlaybd_image_lib + libublksrv_static + ${CURL_LIBRARIES} + ${OPENSSL_SSL_LIBRARY} + ${OPENSSL_CRYPTO_LIBRARY} + ${AIO_LIBRARIES} +) +install(TARGETS overlaybd-ublkd DESTINATION /opt/overlaybd/bin) + if (BUILD_TESTING) add_subdirectory(test) endif () diff --git a/src/ublk/test/CMakeLists.txt b/src/ublk/test/CMakeLists.txt index f6d30a86..10e5935e 100644 --- a/src/ublk/test/CMakeLists.txt +++ b/src/ublk/test/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(ublk_dispatch_test ../io_dispatch.cpp ../cli.cpp ../config_patch.cpp + ../ublkd_protocol.cpp ) target_include_directories(ublk_dispatch_test PUBLIC ${UBLKSRV_INCLUDE_DIRS} ${LIBURING_INCLUDE_DIRS} ${RAPIDJSON_INCLUDE_DIRS}) diff --git a/src/ublk/test/ublk_dispatch_test.cpp b/src/ublk/test/ublk_dispatch_test.cpp index 4ab228b2..54f53177 100644 --- a/src/ublk/test/ublk_dispatch_test.cpp +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -296,3 +296,65 @@ TEST(config_patch, detects_writable_upper) { EXPECT_FALSE(ublk_config_has_upper("not json")); } +// --------------------------------------------------------------------------- +// ublkd control protocol codec (ADR-0006): pure JSON, no daemon needed +// --------------------------------------------------------------------------- +#include "../ublkd_protocol.h" + +TEST(ublkd_protocol, parse_add_full_and_defaults) { + UblkdAddRequest req; + std::string err; + ASSERT_EQ(ublkd_parse_add( + R"({"config":"/tmp/c.json","dev_id":3,"queue_depth":64,)" + R"("instance_id":"snap-1","future_field":true})", + req, err), + 0); // unknown fields ignored: forward compatibility + EXPECT_EQ(req.config, "/tmp/c.json"); + EXPECT_EQ(req.dev_id, 3); + EXPECT_EQ(req.queue_depth, 64); + EXPECT_EQ(req.instance_id, "snap-1"); + + UblkdAddRequest defaults; + ASSERT_EQ(ublkd_parse_add(R"({"config":"/tmp/c.json"})", defaults, err), 0); + EXPECT_EQ(defaults.dev_id, -1); // kernel allocates + EXPECT_EQ(defaults.queue_depth, 128); // CLI default kept +} + +TEST(ublkd_protocol, parse_add_rejects_malformed) { + UblkdAddRequest req; + std::string err; + EXPECT_EQ(ublkd_parse_add("not json", req, err), -1); + EXPECT_EQ(ublkd_parse_add(R"({"dev_id":1})", req, err), -1); // no config + EXPECT_EQ(ublkd_parse_add(R"({"config":""})", req, err), -1); + EXPECT_EQ(ublkd_parse_add(R"({"config":"/c","queue_depth":0})", req, err), -1); + EXPECT_EQ(ublkd_parse_add(R"({"config":"/c","dev_id":"x"})", req, err), -1); + EXPECT_FALSE(err.empty()); +} + +TEST(ublkd_protocol, parse_del) { + int id = -1; + std::string err; + ASSERT_EQ(ublkd_parse_del(R"({"dev_id":7})", id, err), 0); + EXPECT_EQ(id, 7); + EXPECT_EQ(ublkd_parse_del(R"({"dev_id":-1})", id, err), -1); + EXPECT_EQ(ublkd_parse_del(R"({})", id, err), -1); +} + +TEST(ublkd_protocol, responses_roundtrip) { + EXPECT_EQ(ublkd_msg_ok(), R"({"ok":true})"); + EXPECT_NE(ublkd_msg_error("boom").find(R"("ok":false)"), std::string::npos); + auto added = ublkd_msg_added(2, "/dev/ublkb2"); + EXPECT_NE(added.find(R"("dev_id":2)"), std::string::npos); + EXPECT_NE(added.find("/dev/ublkb2"), std::string::npos); + + std::vector devs(1); + devs[0].dev_id = 0; + devs[0].dev_path = "/dev/ublkb0"; + devs[0].config = "/tmp/c.json"; + devs[0].writable = true; + devs[0].state = "running"; + auto list = ublkd_msg_list(devs); + EXPECT_NE(list.find(R"("writable":true)"), std::string::npos); + EXPECT_NE(list.find(R"("state":"running")"), std::string::npos); +} + diff --git a/src/ublk/ublk_device.cpp b/src/ublk/ublk_device.cpp index 13fc7196..f57cbd5d 100644 --- a/src/ublk/ublk_device.cpp +++ b/src/ublk/ublk_device.cpp @@ -103,43 +103,32 @@ class ImageFileTarget : public UblkIOTarget { }; // --------------------------------------------------------------------------- -// UblkDevice: all state of one device, formerly file-scope globals. -// One instance == one device; a process may in principle host several -// instances, which is what the daemon mode (ADR-0005) will build on. +// UblkDevice small members (the class declaration lives in ublk_device.h; +// one instance == one device, N instances per process is the daemon mode) // --------------------------------------------------------------------------- -class UblkDevice { -public: - // Run the device until stop() is called (usually from a signal handler). - // Returns the process exit code. - int run(const UblkDeviceOpts &opts, int ready_fd); +void UblkDevice::stop() { + if (ctrl_dev_ != nullptr) + ublksrv_ctrl_stop_dev(ctrl_dev_); +} - // Ask the device to stop; safe to call from a photon signal coroutine. - void stop() { - if (ctrl_dev_ != nullptr) - ublksrv_ctrl_stop_dev(ctrl_dev_); - } +uint64_t UblkDevice::image_size() const { + return file_->num_lbas * (uint64_t)file_->block_size; +} - uint64_t image_size() const { - return file_->num_lbas * (uint64_t)file_->block_size; - } +bool UblkDevice::writable() const { + return file_ != nullptr && !file_->read_only; +} -private: - int setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_root); - int claim_instance(const std::string &image_root, const std::string &inst, - std::string &cache_root); - void set_dev_parameters(); - void ctrl_del_and_deinit(); - void queue_loop(const struct ublksrv_dev *dev, photon::semaphore *started, - int *init_ret); - - ImageService *imgservice_ = nullptr; - ImageFile *file_ = nullptr; - ImageFileTarget *target_ = nullptr; - struct ublksrv_ctrl_dev *ctrl_dev_ = nullptr; - std::atomic queue_exited_{false}; - int cache_lock_fd_ = -1; // held for the device's lifetime, released on exit -}; +UblkDevice::~UblkDevice() { + // safety net for the daemon path; the normal orders are run(), or + // stop() -> wait() -> teardown() -- every step below is idempotent + stop(); + wait(); + teardown(); + if (cache_lock_fd_ >= 0) + close(cache_lock_fd_); +} // --------------------------------------------------------------------------- // data plane: per-queue photon-owned hybrid event loop @@ -201,6 +190,26 @@ static struct ublksrv_tgt_type obd_tgt_type = { .name = "overlaybd", }; +// Probe IORING_SETUP_COOP_TASKRUN (mainline 5.19+) support once per process +// with a private throwaway ring. Letting ublksrv_queue_init_flags fail and +// retrying without the flag looks harmless, but libublksrv's failure path +// runs ublksrv_queue_deinit on a zero-initialized queue whose epollfd/efd +// are 0 behind `>= 0` guards -- it closes fd 0. With one process per device +// the victim is just stdin (and the retried ring lands on fd 0, working by +// luck); in the daemon, fd 0 IS the previous device's queue ring, so every +// add silently wedged the device added before it. Never let the first +// attempt fail. +static unsigned probe_queue_ring_flags() { + struct io_uring probe; + int r = io_uring_queue_init(2, &probe, IORING_SETUP_COOP_TASKRUN); + if (r == 0) { + io_uring_queue_exit(&probe); + return IORING_SETUP_COOP_TASKRUN; + } + LOG_WARN("IORING_SETUP_COOP_TASKRUN unsupported (`), queues run without it", r); + return 0; +} + // The queue thread owns its own photon env and acts as the event-loop master: // poll the io_uring ring fd via photon epoll, reap CQEs only when readable // (never enter ublksrv_process_io's blocking wait), submit queued SQEs after @@ -221,17 +230,11 @@ void UblkDevice::queue_loop(const struct ublksrv_dev *dev, photon::semaphore *st ctx.pool = &pool; ctx.target = target_; - // IORING_SETUP_COOP_TASKRUN (mainline 5.19+) is a pure optimization, but - // ublksrv_queue_init() hardcodes it. Backport kernels shipping ublk_drv on - // older baselines (e.g. Alinux 5.10.134) may lack it and fail with EINVAL, - // so retry without the flag before giving up. const struct ublksrv_queue *q = - ublksrv_queue_init_flags(dev, 0, &ctx, IORING_SETUP_COOP_TASKRUN); - if (q == nullptr) { - LOG_WARN("queue init with IORING_SETUP_COOP_TASKRUN failed, retrying without it"); - q = ublksrv_queue_init_flags(dev, 0, &ctx, 0); - } + ublksrv_queue_init_flags(dev, 0, &ctx, ring_flags_); if (q == nullptr) { + // no blind retry: every failed init runs the library's broken + // cleanup path (closes fd 0), see probe_queue_ring_flags() LOG_ERROR("ublksrv_queue_init failed (see syslog for libublksrv details)"); *init_ret = -1; queue_exited_.store(true); @@ -530,27 +533,22 @@ int UblkDevice::setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_ return -1; } -int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { - if (ublk_check_control_dev() != 0) - return 1; - mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); // pidfile dir for libublksrv - +// CLI path only: mandatory cache isolation (ADR-0004) + own ImageService +int UblkDevice::init_service(const UblkDeviceOpts &opts) { std::string cache_root; if (setup_cache_root(opts, cache_root) != 0) - return 1; + return -1; std::string patched_config; // temp file, removed after service init if (make_patched_service_config(opts, cache_root, patched_config) != 0) - return 1; - std::string service_config = patched_config; + return -1; - imgservice_ = create_image_service( - service_config.empty() ? nullptr : service_config.c_str()); - if (!patched_config.empty()) - unlink(patched_config.c_str()); // config parsed, temp copy no longer needed + imgservice_ = create_image_service(patched_config.c_str()); + unlink(patched_config.c_str()); // config parsed, temp copy no longer needed if (imgservice_ == nullptr) { LOG_ERROR("failed to create image service"); - return 1; + return -1; } + owns_service_ = true; // per-device log: redirect after create_image_service (which applied the // shared logPath from the global config); reuse the global size/rotation if (!opts.log_path.empty()) { @@ -564,17 +562,54 @@ int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { else LOG_INFO("per-device log: `", opts.log_path.c_str()); } - // tag with pid so that daemons sharing one log remain distinguishable + return 0; +} + +// start() failed mid-way: remove whatever was created so that neither the +// kernel nor this object keeps a half-created device (ADR-0006 hard rule) +void UblkDevice::cleanup_failed_start(bool dev_added) { + if (ctrl_dev_ != nullptr) { + if (dev_added) { + ctrl_del_and_deinit(); + } else { + ublksrv_ctrl_deinit(ctrl_dev_); + ctrl_dev_ = nullptr; + } + } + delete target_; + target_ = nullptr; + delete file_; + file_ = nullptr; + dev_id_ = -1; +} + +int UblkDevice::start(const UblkDeviceOpts &opts) { + if (imgservice_ == nullptr) { + LOG_ERROR("no ImageService bound (daemon must inject one)"); + return -1; + } + // registration tag: ImageService keys its device table by this string, + // so it must be unique. CLI: pid alone (one device per process, matches + // the log tag). Daemon: many devices share the pid, append a sequence -- + // a colliding tag fails the second create_image_file outright + // (register_image_file: "dev id exists"). std::string dev_tag = "ublk-" + std::to_string(getpid()); + if (!owns_service_) { + static std::atomic seq{0}; + dev_tag += "-" + std::to_string(seq.fetch_add(1)); + } // open the image before anything ublk-related: dev_size is needed by // init_tgt, and a broken config must fail the add command, not the device file_ = imgservice_->create_image_file(opts.image_config_path.c_str(), dev_tag); if (file_ == nullptr) { LOG_ERROR("failed to open image `", opts.image_config_path.c_str()); - return 1; + return -1; } target_ = new ImageFileTarget(file_); + // probe once, main-thread serialized, before any queue thread exists + ring_flags_ = probe_queue_ring_flags(); + struct ublksrv_dev_data data; memset(&data, 0, sizeof(data)); data.dev_id = opts.dev_id; @@ -589,7 +624,8 @@ int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { ctrl_dev_ = ublksrv_ctrl_init(&data); if (ctrl_dev_ == nullptr) { LOG_ERROR("ublksrv_ctrl_init failed"); - return 1; + cleanup_failed_start(false); + return -1; } // route the owning device into libublksrv callbacks (obd_init_tgt) ublksrv_ctrl_set_priv_data(ctrl_dev_, this); @@ -597,70 +633,100 @@ int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { int ret = ublksrv_ctrl_add_dev(ctrl_dev_); if (ret < 0) { LOG_ERROR("ublksrv_ctrl_add_dev failed: `", ret); - ublksrv_ctrl_deinit(ctrl_dev_); - ctrl_dev_ = nullptr; - return 1; + cleanup_failed_start(false); + return -1; } - int dev_id = ublksrv_ctrl_get_dev_info(ctrl_dev_)->dev_id; + dev_id_ = ublksrv_ctrl_get_dev_info(ctrl_dev_)->dev_id; ret = ublksrv_ctrl_get_affinity(ctrl_dev_); if (ret < 0) LOG_WARN("ublksrv_ctrl_get_affinity failed: `", ret); - { - const struct ublksrv_dev *dev = ublksrv_dev_init(ctrl_dev_); - if (dev == nullptr) { - LOG_ERROR("ublksrv_dev_init failed"); - goto fail_del_dev; - } - - photon::semaphore queue_started; - int queue_init_ret = -1; - std::thread queue_thread(&UblkDevice::queue_loop, this, dev, &queue_started, - &queue_init_ret); - queue_started.wait(1); - if (queue_init_ret != 0) { - queue_thread.join(); - ublksrv_dev_deinit(dev); - goto fail_del_dev; - } + dev_ = ublksrv_dev_init(ctrl_dev_); + if (dev_ == nullptr) { + LOG_ERROR("ublksrv_dev_init failed"); + cleanup_failed_start(true); + return -1; + } - set_dev_parameters(); - - ret = ublksrv_ctrl_start_dev(ctrl_dev_, getpid()); - if (ret < 0) { - LOG_ERROR("ublksrv_ctrl_start_dev failed: `", ret); - ublksrv_ctrl_stop_dev(ctrl_dev_); // unblock the queue thread - while (!queue_exited_.load()) - photon::thread_usleep(10 * 1000); - queue_thread.join(); - ublksrv_dev_deinit(dev); - goto fail_del_dev; - } + photon::semaphore queue_started; + int queue_init_ret = -1; + queue_thread_ = std::thread(&UblkDevice::queue_loop, this, dev_, &queue_started, + &queue_init_ret); + queue_started.wait(1); + if (queue_init_ret != 0) { + queue_thread_.join(); + ublksrv_dev_deinit(dev_); + dev_ = nullptr; + cleanup_failed_start(true); + return -1; + } - LOG_INFO("overlaybd-ublk device /dev/ublkb` ready, image: `", dev_id, - opts.image_config_path.c_str()); - notify_ready(ready_fd, dev_id); + set_dev_parameters(); - // photon-sleep instead of a bare join: sync_signal handlers run as - // photon coroutines in this thread and must keep getting scheduled + ret = ublksrv_ctrl_start_dev(ctrl_dev_, getpid()); + if (ret < 0) { + LOG_ERROR("ublksrv_ctrl_start_dev failed: `", ret); + ublksrv_ctrl_stop_dev(ctrl_dev_); // unblock the queue thread while (!queue_exited_.load()) - photon::thread_usleep(200 * 1000); - queue_thread.join(); - ublksrv_dev_deinit(dev); + photon::thread_usleep(10 * 1000); + queue_thread_.join(); + ublksrv_dev_deinit(dev_); + dev_ = nullptr; + cleanup_failed_start(true); + return -1; } - ctrl_del_and_deinit(); + LOG_INFO("overlaybd-ublk device /dev/ublkb` ready, image: `", dev_id_, + opts.image_config_path.c_str()); + return 0; +} +void UblkDevice::wait() { + if (!queue_thread_.joinable()) + return; + // photon-sleep instead of a bare join: photon coroutines of this thread + // (signal handlers, daemon control requests) must keep getting scheduled + while (!queue_exited_.load()) + photon::thread_usleep(200 * 1000); + queue_thread_.join(); + if (dev_ != nullptr) { + ublksrv_dev_deinit(dev_); + dev_ = nullptr; + } +} + +void UblkDevice::teardown() { + if (torn_down_) + return; + torn_down_ = true; + if (ctrl_dev_ != nullptr) + ctrl_del_and_deinit(); delete target_; + target_ = nullptr; delete file_; - delete imgservice_; + file_ = nullptr; + if (owns_service_) + delete imgservice_; + imgservice_ = nullptr; +} + +int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { + if (ublk_check_control_dev() != 0) + return 1; + mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); // pidfile dir for libublksrv + + if (init_service(opts) != 0) + return 1; + if (start(opts) != 0) { + teardown(); + return 1; + } + notify_ready(ready_fd, dev_id_); + wait(); + teardown(); LOG_INFO("overlaybd-ublk exited"); return 0; - -fail_del_dev: - ctrl_del_and_deinit(); - return 1; } // --------------------------------------------------------------------------- diff --git a/src/ublk/ublk_device.h b/src/ublk/ublk_device.h index 03d13c61..257a18fb 100644 --- a/src/ublk/ublk_device.h +++ b/src/ublk/ublk_device.h @@ -15,7 +15,9 @@ */ #pragma once +#include #include +#include // pidfiles (.pid, created by libublksrv) live here; `del` and `list` // discover overlaybd-ublk daemons through this directory only. @@ -53,3 +55,73 @@ int ublk_check_control_dev(); // is closed afterwards unless it is stdout/stderr. Returns the process // exit code. int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd); + +class ImageService; +class ImageFile; +class ImageFileTarget; +struct ublksrv_ctrl_dev; +struct ublksrv_dev; +namespace photon { +class semaphore; +} + +// One ublk device: image open, ctrl dev lifecycle, queue event-loop thread. +// Two construction paths (ADR-0006): +// - CLI (default ctor + run()): owns its ImageService, with the mandatory +// per-image cache isolation of ADR-0004 (image key + instance slots); +// - daemon (inject a shared ImageService + start/wait/stop): cache patching +// and instance slots are skipped -- all devices of the daemon share one +// cache directory, whose concurrency is covered by in-process locks. +class UblkDevice { +public: + UblkDevice() = default; + explicit UblkDevice(ImageService *shared_service) + : imgservice_(shared_service), owns_service_(false) { + } + ~UblkDevice(); + + UblkDevice(const UblkDevice &) = delete; + UblkDevice &operator=(const UblkDevice &) = delete; + + // CLI blocking path: init_service + start + readiness + wait + teardown. + int run(const UblkDeviceOpts &opts, int ready_fd); + + // Daemon path. start() brings the device up (0 = ready, dev_id() valid); + // a partial device is cleaned up internally on failure (no leftovers in + // the kernel). wait() blocks until the queue exits and releases the + // queue/dev resources; teardown() deletes the ublk device and the image. + int start(const UblkDeviceOpts &opts); + void wait(); + void stop(); // request stop; safe from a photon signal coroutine + void teardown(); + + int dev_id() const { + return dev_id_; + } + bool writable() const; + uint64_t image_size() const; // needed by the init_tgt callback + +private: + int init_service(const UblkDeviceOpts &opts); // CLI path only + void cleanup_failed_start(bool dev_added); // no half-created device stays + int setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_root); + int claim_instance(const std::string &image_root, const std::string &inst, + std::string &cache_root); + void set_dev_parameters(); + void ctrl_del_and_deinit(); + void queue_loop(const struct ublksrv_dev *dev, photon::semaphore *started, + int *init_ret); + + ImageService *imgservice_ = nullptr; + ImageFile *file_ = nullptr; + ImageFileTarget *target_ = nullptr; + struct ublksrv_ctrl_dev *ctrl_dev_ = nullptr; + const struct ublksrv_dev *dev_ = nullptr; + std::thread queue_thread_; + std::atomic queue_exited_{false}; + unsigned ring_flags_ = 0; // queue io_uring flags, probed in start() + int cache_lock_fd_ = -1; // held for the device's lifetime + int dev_id_ = -1; + bool owns_service_ = true; + bool torn_down_ = false; +}; diff --git a/src/ublk/ublkd_main.cpp b/src/ublk/ublkd_main.cpp new file mode 100644 index 00000000..dc8ea787 --- /dev/null +++ b/src/ublk/ublkd_main.cpp @@ -0,0 +1,262 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// overlaybd-ublkd: the centralized ublk device manager daemon (ADR-0006 M1). +// All devices live in this process (one queue thread each) and share one +// ImageService; control plane is HTTP over a unix domain socket +// (docker.sock style), M1 handles requests globally serialized. + +#include "ublk_device.h" +#include "ublkd_protocol.h" + +#include "../image_service.h" +#include "../version.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include "../tools/CLI11.hpp" + +#define UBLKD_DEFAULT_SOCK OVERLAYBD_UBLK_RUN_DIR "/ublkd.sock" + +class UblkdServer : public photon::net::http::HTTPHandler { +public: + UblkdServer(ImageService *service) : service_(service) { + } + + ~UblkdServer() { + // stop all devices in parallel: STOP first, join afterwards, so the + // total teardown time is about the slowest device instead of the sum + // (the bounded-DEL fallback costs 2s per device on backport kernels) + for (auto &kv : devices_) + kv.second->stop(); + for (auto &kv : devices_) { + kv.second->wait(); + kv.second->teardown(); + } + devices_.clear(); + } + + int handle_request(photon::net::http::Request &req, photon::net::http::Response &resp, + std::string_view) override { + std::string target(req.target()); + std::string body = read_body(req); + int code = 404; + std::string msg = ublkd_msg_error("unknown endpoint: " + target); + + if (target == "/v1/add" && req.verb() == photon::net::http::Verb::POST) { + handle_add(body, code, msg); + } else if (target == "/v1/del" && req.verb() == photon::net::http::Verb::POST) { + handle_del(body, code, msg); + } else if (target == "/v1/list") { + handle_list(code, msg); + } else if (target == "/v1/ping") { + code = 200; + msg = ublkd_msg_ping(OVERLAYBD_VERSION); + } else if (target == "/v1/shutdown" && + req.verb() == photon::net::http::Verb::POST) { + code = 200; + msg = ublkd_msg_ok(); + shutdown_requested = true; + } + + resp.set_result(code); + resp.headers.content_length(msg.size()); + ssize_t w = resp.write((void *)msg.c_str(), msg.size()); + if (w != (ssize_t)msg.size()) + LOG_ERRNO_RETURN(0, -1, "ublkd: send response failed, target: `", target); + return 0; + } + + bool shutdown_requested = false; + +private: + static std::string read_body(photon::net::http::Request &req) { + std::string body; + size_t n = req.body_size(); + if (n == 0 || n > 64 * 1024) // requests are tiny; cap for safety + return body; + body.resize(n); + ssize_t r = req.read(&body[0], n); + if (r != (ssize_t)n) + body.clear(); + return body; + } + + void handle_add(const std::string &body, int &code, std::string &msg) { + UblkdAddRequest areq; + std::string err; + if (ublkd_parse_add(body, areq, err) != 0) { + code = 400; + msg = ublkd_msg_error(err); + return; + } + auto dev = std::make_unique(service_); + UblkDeviceOpts opts; + opts.image_config_path = areq.config; + opts.dev_id = areq.dev_id; + opts.queue_depth = areq.queue_depth; + // M1: instance_id reserved; cache patching/slots are skipped in the + // injected-service path (shared cache, in-process locks -- ADR-0006) + if (dev->start(opts) != 0) { + code = 500; + msg = ublkd_msg_error("device failed to start (see daemon log)"); + return; + } + int id = dev->dev_id(); + configs_[id] = areq.config; + devices_[id] = std::move(dev); + code = 200; + msg = ublkd_msg_added(id, "/dev/ublkb" + std::to_string(id)); + LOG_INFO("ublkd: added /dev/ublkb` image `", id, areq.config.c_str()); + } + + void handle_del(const std::string &body, int &code, std::string &msg) { + int id = -1; + std::string err; + if (ublkd_parse_del(body, id, err) != 0) { + code = 400; + msg = ublkd_msg_error(err); + return; + } + auto it = devices_.find(id); + if (it == devices_.end()) { + code = 404; + msg = ublkd_msg_error("no such device: " + std::to_string(id)); + return; + } + // M1: synchronous del -- stop, drain, delete, then reply (serial + // control plane; concurrent del/list responsiveness is M2) + it->second->stop(); + it->second->wait(); + it->second->teardown(); + devices_.erase(it); + configs_.erase(id); + code = 200; + msg = ublkd_msg_ok(); + LOG_INFO("ublkd: deleted /dev/ublkb`", id); + } + + void handle_list(int &code, std::string &msg) { + std::vector infos; + for (auto &kv : devices_) { + UblkdDeviceInfo info; + info.dev_id = kv.first; + info.dev_path = "/dev/ublkb" + std::to_string(kv.first); + info.config = configs_[kv.first]; + info.writable = kv.second->writable(); + info.state = "running"; // M1: del is synchronous, no stopping state + infos.push_back(std::move(info)); + } + code = 200; + msg = ublkd_msg_list(infos); + } + + ImageService *service_; + std::map> devices_; + std::map configs_; // dev_id -> image config path +}; + +// entry-layer signal routing (same pattern & rationale as cli g_signal_device) +static bool *g_shutdown_flag = nullptr; + +static void sigterm_handler(int signal) { + LOG_INFO("ublkd: signal ` received, shutting down", signal); + if (g_shutdown_flag != nullptr) + *g_shutdown_flag = true; +} + +int main(int argc, char **argv) { + std::string socket_path = UBLKD_DEFAULT_SOCK; + std::string service_config; + + CLI::App app{"overlaybd-ublkd: centralized ublk device manager daemon " + "(all devices in one process, shared ImageService)"}; + app.add_option("--socket-path", socket_path, + "unix socket for the control API (default " UBLKD_DEFAULT_SOCK ")"); + app.add_option("--service-config", service_config, + "overlaybd service config (default /etc/overlaybd/overlaybd.json)"); + CLI11_PARSE(app, argc, argv); + + if (ublk_check_control_dev() != 0) + return 1; + mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); + + // daemon hygiene: park stdin on /dev/null so fd 0 can never be reused by + // resource fds (a queue io_uring landing on fd 0 was observed once after + // stdin vanished mid-session; any code treating fd 0/1/2 specially then + // corrupts it). stdout/stderr stay attached for foreground logs. + int devnull = open("/dev/null", O_RDWR); + if (devnull > 0) { + dup2(devnull, 0); + close(devnull); + } + + photon::init(photon::INIT_EVENT_DEFAULT, photon::INIT_IO_DEFAULT); + photon::block_all_signal(); + photon::sync_signal(SIGTERM, &sigterm_handler); + photon::sync_signal(SIGINT, &sigterm_handler); + + ImageService *service = + create_image_service(service_config.empty() ? nullptr : service_config.c_str()); + if (service == nullptr) { + fprintf(stderr, "overlaybd-ublkd: failed to create image service\n"); + return 1; + } + + auto *server = new UblkdServer(service); + g_shutdown_flag = &server->shutdown_requested; + + auto *sock = photon::net::new_uds_server(true /* autoremove */); + if (sock->bind(socket_path.c_str()) != 0 || sock->listen() != 0) { + fprintf(stderr, "overlaybd-ublkd: cannot listen on %s: %s\n", + socket_path.c_str(), strerror(errno)); + return 1; + } + chmod(socket_path.c_str(), 0600); // root-only control surface + auto *http = photon::net::http::new_http_server(); + http->add_handler(server, false, "/v1"); + sock->set_handler(http->get_connection_handler()); + sock->start_loop(false); + LOG_INFO("overlaybd-ublkd ready, control socket: `", socket_path.c_str()); + + // main coroutine idles here; signal handlers and connection handlers run + // as photon coroutines in this thread + while (!server->shutdown_requested) + photon::thread_usleep(200 * 1000); + + LOG_INFO("overlaybd-ublkd: draining devices and exiting"); + sock->terminate(); + delete http; + delete server; // parallel-stops and tears down every device + delete sock; + delete service; + photon::fini(); + return 0; +} diff --git a/src/ublk/ublkd_protocol.cpp b/src/ublk/ublkd_protocol.cpp new file mode 100644 index 00000000..a97a87ae --- /dev/null +++ b/src/ublk/ublkd_protocol.cpp @@ -0,0 +1,131 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#include "ublkd_protocol.h" + +#include +#include +#include + +int ublkd_parse_add(const std::string &body, UblkdAddRequest &req, std::string &err) { + rapidjson::Document doc; + doc.Parse(body.c_str()); + if (doc.HasParseError() || !doc.IsObject()) { + err = "request body is not a JSON object"; + return -1; + } + if (!doc.HasMember("config") || !doc["config"].IsString() || + doc["config"].GetStringLength() == 0) { + err = "missing required field: config"; + return -1; + } + req.config = doc["config"].GetString(); + if (doc.HasMember("dev_id")) { + if (!doc["dev_id"].IsInt()) { + err = "dev_id must be an integer"; + return -1; + } + req.dev_id = doc["dev_id"].GetInt(); + } + if (doc.HasMember("queue_depth")) { + if (!doc["queue_depth"].IsInt() || doc["queue_depth"].GetInt() < 1 || + doc["queue_depth"].GetInt() > 4096) { + err = "queue_depth must be an integer in [1, 4096]"; + return -1; + } + req.queue_depth = doc["queue_depth"].GetInt(); + } + if (doc.HasMember("instance_id")) { + if (!doc["instance_id"].IsString()) { + err = "instance_id must be a string"; + return -1; + } + req.instance_id = doc["instance_id"].GetString(); + } + return 0; +} + +int ublkd_parse_del(const std::string &body, int &dev_id, std::string &err) { + rapidjson::Document doc; + doc.Parse(body.c_str()); + if (doc.HasParseError() || !doc.IsObject()) { + err = "request body is not a JSON object"; + return -1; + } + if (!doc.HasMember("dev_id") || !doc["dev_id"].IsInt() || doc["dev_id"].GetInt() < 0) { + err = "missing or invalid field: dev_id"; + return -1; + } + dev_id = doc["dev_id"].GetInt(); + return 0; +} + +static std::string dump(const rapidjson::Document &doc) { + rapidjson::StringBuffer sb; + rapidjson::Writer writer(sb); + doc.Accept(writer); + return sb.GetString(); +} + +std::string ublkd_msg_ok() { + return R"({"ok":true})"; +} + +std::string ublkd_msg_error(const std::string &error) { + rapidjson::Document doc; + doc.SetObject(); + auto &a = doc.GetAllocator(); + doc.AddMember("ok", false, a); + doc.AddMember("error", rapidjson::Value(error.c_str(), a), a); + return dump(doc); +} + +std::string ublkd_msg_added(int dev_id, const std::string &path) { + rapidjson::Document doc; + doc.SetObject(); + auto &a = doc.GetAllocator(); + doc.AddMember("ok", true, a); + doc.AddMember("dev_id", dev_id, a); + doc.AddMember("dev", rapidjson::Value(path.c_str(), a), a); + return dump(doc); +} + +std::string ublkd_msg_list(const std::vector &devices) { + rapidjson::Document doc; + doc.SetObject(); + auto &a = doc.GetAllocator(); + doc.AddMember("ok", true, a); + rapidjson::Value arr(rapidjson::kArrayType); + for (const auto &d : devices) { + rapidjson::Value o(rapidjson::kObjectType); + o.AddMember("dev_id", d.dev_id, a); + o.AddMember("dev", rapidjson::Value(d.dev_path.c_str(), a), a); + o.AddMember("config", rapidjson::Value(d.config.c_str(), a), a); + o.AddMember("writable", d.writable, a); + o.AddMember("state", rapidjson::Value(d.state.c_str(), a), a); + arr.PushBack(o, a); + } + doc.AddMember("devices", arr, a); + return dump(doc); +} + +std::string ublkd_msg_ping(const std::string &version) { + rapidjson::Document doc; + doc.SetObject(); + auto &a = doc.GetAllocator(); + doc.AddMember("ok", true, a); + doc.AddMember("version", rapidjson::Value(version.c_str(), a), a); + return dump(doc); +} diff --git a/src/ublk/ublkd_protocol.h b/src/ublk/ublkd_protocol.h new file mode 100644 index 00000000..fe1b0f4b --- /dev/null +++ b/src/ublk/ublkd_protocol.h @@ -0,0 +1,53 @@ +/* + Copyright The Overlaybd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +#pragma once + +#include +#include + +// Request/response codec of the overlaybd-ublkd control protocol +// (HTTP over UDS, /v1/* endpoints -- ADR-0006). Pure JSON logic on +// rapidjson, deliberately free of photon/ublksrv so it unit-tests like +// cli.cpp/config_patch.cpp. Unknown JSON fields are ignored (forward +// compatibility). + +struct UblkdAddRequest { + std::string config; // image config path, required + std::string instance_id; // reserved (unused until M2 cache work) + int dev_id = -1; // -1: kernel allocates + int queue_depth = 128; +}; + +struct UblkdDeviceInfo { + int dev_id = -1; + std::string dev_path; // /dev/ublkbN + std::string config; + bool writable = false; + std::string state; // "running" / "stopping" +}; + +// parse POST /v1/add body; 0 = ok, -1 = malformed (err says why) +int ublkd_parse_add(const std::string &body, UblkdAddRequest &req, std::string &err); + +// parse POST /v1/del body ({"dev_id":N}); 0 = ok, -1 = malformed +int ublkd_parse_del(const std::string &body, int &dev_id, std::string &err); + +// response builders (single-line JSON) +std::string ublkd_msg_ok(); // {"ok":true} +std::string ublkd_msg_error(const std::string &error); // {"ok":false,...} +std::string ublkd_msg_added(int dev_id, const std::string &path); // add success +std::string ublkd_msg_list(const std::vector &devices); +std::string ublkd_msg_ping(const std::string &version); From c6dfc3a74ef02c2d4846ba0b6c359801fd9e5bd5 Mon Sep 17 00:00:00 2001 From: haolianglh Date: Tue, 28 Jul 2026 15:29:57 +0800 Subject: [PATCH 5/8] Add cache locking and concurrency guards to ublkd Land the safety items of the daemon milestone M2 (ADR-0006): * The daemon now patches the service config to a private cache tree /daemon/{registry_cache,gzip_cache} guarded by an exclusive flock (--cache-dir overrides the base; the service config's cacheDir is deliberately ignored so the default never collides with tcmu's cache directory). A second daemon on the same base is refused. * Writable images are excluded across processes: the daemon takes the per-image inst0 lock file (lock-only) before opening the upper, so the daemon and single-device CLI processes cannot double-mount one RW image; the lock is released when the device is deleted. * The CLI `del -n N` now refuses devices owned by overlaybd-ublkd and prints the equivalent curl command: the pidfile of such a device points at the daemon, so the old SIGTERM semantics tore down ALL daemon devices at once (found the hard way). `list` tags these devices with [ublkd-managed]. * Concurrent control requests are made safe: a per-image in-flight guard rejects duplicate adds, and a stopping state machine rejects concurrent deletes of one device, which previously raced on the device-table iterator across coroutine yields. * Failure reasons of a daemonized CLI add now travel to the console over the ready pipe (the child has no stderr and the log is not yet redirected during early setup), and the patched service config copy stays alive until teardown -- deleting it early silently dropped the global default download section from image configs. Verified on a ublk-capable kernel: dual-daemon refusal, bidirectional RW mount exclusion, RO cross-mounting, concurrent add/del races, del rejection with a live daemon, and full teardown via SIGTERM. Signed-off-by: haolianglh --- src/ublk/main.cpp | 42 ++++++++- src/ublk/ublk_device.cpp | 192 ++++++++++++++++++++++++++++++++------- src/ublk/ublk_device.h | 20 ++++ src/ublk/ublkd_main.cpp | 105 ++++++++++++++++----- 4 files changed, 299 insertions(+), 60 deletions(-) diff --git a/src/ublk/main.cpp b/src/ublk/main.cpp index 8bf9e2a2..7a666d70 100644 --- a/src/ublk/main.cpp +++ b/src/ublk/main.cpp @@ -44,6 +44,24 @@ static int read_pidfile(int dev_id, pid_t *pid) { return 0; } +// A pidfile under the run dir may belong to a one-device CLI daemon or to +// overlaybd-ublkd (libublksrv writes .pid with the serving process' +// pid either way). The distinction matters: SIGTERM to a CLI daemon removes +// exactly its device, but SIGTERM to ublkd gracefully tears down ALL its +// devices -- `del -n N` must never do that by accident. +static bool pid_is_ublkd(pid_t pid) { + char path[64], comm[64] = {}; + snprintf(path, sizeof(path), "/proc/%d/comm", (int)pid); + FILE *fp = fopen(path, "r"); + if (fp == nullptr) + return false; + if (fgets(comm, sizeof(comm), fp) == nullptr) + comm[0] = 0; + fclose(fp); + comm[strcspn(comm, "\n")] = 0; + return strcmp(comm, "overlaybd-ublkd") == 0; +} + // del: process == device, so a graceful stop is SIGTERM to the // daemon, whose signal handler tears the ublk device down before exiting. static int cmd_del(int dev_id) { @@ -53,6 +71,16 @@ static int cmd_del(int dev_id) { OVERLAYBD_UBLK_RUN_DIR); return 1; } + if (pid_is_ublkd(pid)) { + fprintf(stderr, + "overlaybd-ublk: /dev/ublkb%d is managed by overlaybd-ublkd " + "(pid %d); SIGTERM would tear down ALL of its devices.\n" + "Use the daemon API instead:\n" + " curl --unix-socket %s/ublkd.sock -X POST " + "-d '{\"dev_id\":%d}' http://d/v1/del\n", + dev_id, (int)pid, OVERLAYBD_UBLK_RUN_DIR, dev_id); + return 1; + } if (kill(pid, SIGTERM) != 0) { if (errno == ESRCH) { fprintf(stderr, "overlaybd-ublk: dev %d daemon (pid %d) not running, " @@ -108,8 +136,9 @@ static int cmd_list() { cmdline = buf; } } - printf("/dev/ublkb%-4d pid %-8d %-8s %s\n", dev_id, (int)pid, - alive ? "running" : "dead", cmdline.c_str()); + printf("/dev/ublkb%-4d pid %-8d %-8s %s%s\n", dev_id, (int)pid, + alive ? "running" : "dead", + pid_is_ublkd(pid) ? "[ublkd-managed] " : "", cmdline.c_str()); } closedir(dir); return 0; @@ -155,7 +184,7 @@ static int cmd_add(const UblkDeviceOpts &opts, bool foreground) { // parent: block until the child reports readiness or dies (pipe EOF) close(pipefd[1]); - char buf[64]; + char buf[256]; ssize_t n = 0, off = 0; while (off < (ssize_t)sizeof(buf) - 1 && (n = read(pipefd[0], buf + off, sizeof(buf) - 1 - off)) > 0) { @@ -164,11 +193,18 @@ static int cmd_add(const UblkDeviceOpts &opts, bool foreground) { break; } close(pipefd[0]); + buf[off > 0 ? off : 0] = 0; if (off > 0 && strncmp(buf, "/dev/", 5) == 0) { fwrite(buf, 1, off, stdout); // e.g. "/dev/ublkb0\n" return 0; } + if (off > 4 && strncmp(buf, "ERR:", 4) == 0) { + // failure reason relayed over the ready pipe: the daemonized child + // has no stderr and alog may not be file-backed yet at that point + fprintf(stderr, "overlaybd-ublk: %s", buf + 4); + return 1; + } fprintf(stderr, "overlaybd-ublk: device failed to start " "(check the overlaybd log for details)\n"); return 1; diff --git a/src/ublk/ublk_device.cpp b/src/ublk/ublk_device.cpp index f57cbd5d..f7741773 100644 --- a/src/ublk/ublk_device.cpp +++ b/src/ublk/ublk_device.cpp @@ -332,6 +332,22 @@ static void notify_ready(int ready_fd, int dev_id) { close(ready_fd); } +// failure counterpart of the sync-ready contract: the daemonized child has +// no usable stderr and alog is not yet file-backed during early setup, so +// the reason travels to the add command's console through the same pipe +static void notify_error(int ready_fd, const std::string &reason) { + if (ready_fd <= 2) // foreground mode already printed to a live stderr + return; + std::string msg = + "ERR:" + + (reason.empty() ? std::string("unknown error, check the overlaybd log") + : reason) + + "\n"; + ssize_t nwritten = write(ready_fd, msg.data(), msg.size()); + (void)nwritten; + close(ready_fd); +} + // Delete the ublk device and release the ctrl dev, without ever blocking // this process forever. Sync DEL_DEV waits in-kernel until every device // reference is dropped; on some kernels (observed: 5.10 backport) the last @@ -388,12 +404,12 @@ static int mkdir_p(const std::string &path, mode_t mode) { // config into the run dir and pointing create_image_service at it (keeps the // shared image_service code untouched). The temp file is removed right after // the service is created. -static int make_patched_service_config(const UblkDeviceOpts &opts, +static int make_patched_service_config(const std::string &service_config_path, const std::string &cache_root, std::string &out_path) { - const char *base_path = opts.service_config_path.empty() + const char *base_path = service_config_path.empty() ? "/etc/overlaybd/overlaybd.json" // create_image_service default - : opts.service_config_path.c_str(); + : service_config_path.c_str(); std::ifstream in(base_path); if (!in) { fprintf(stderr, "overlaybd-ublk: cannot read service config %s\n", base_path); @@ -439,30 +455,43 @@ static bool valid_instance_id(const std::string &s) { return true; } -// claim /: 0 = claimed, -2 = busy, -1 = hard error -int UblkDevice::claim_instance(const std::string &image_root, const std::string &inst, - std::string &cache_root) { - std::string root = image_root + "/" + inst; - if (mkdir_p(root + "/registry_cache", 0755) != 0 || - mkdir_p(root + "/gzip_cache", 0755) != 0) { - fprintf(stderr, "overlaybd-ublk: cannot create cache dir %s: %s\n", root.c_str(), +// lock /.lock exclusively: 0 = locked (fd_out valid, keep for the +// holder's lifetime; kernel releases on any process exit), -2 = busy, +// -1 = hard error +static int lock_dir(const std::string &dir, int &fd_out) { + if (mkdir_p(dir, 0755) != 0) { + fprintf(stderr, "overlaybd-ublk: cannot create %s: %s\n", dir.c_str(), strerror(errno)); return -1; } - std::string lock_path = root + "/.lock"; + std::string lock_path = dir + "/.lock"; int fd = open(lock_path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644); if (fd < 0) { fprintf(stderr, "overlaybd-ublk: cannot open %s: %s\n", lock_path.c_str(), strerror(errno)); return -1; } - // held for the daemon's lifetime; the kernel releases it on any kind of - // process exit, so there is no stale-lock handling to do if (flock(fd, LOCK_EX | LOCK_NB) != 0) { close(fd); return -2; } - cache_lock_fd_ = fd; + fd_out = fd; + return 0; +} + +// claim /: 0 = claimed, -2 = busy, -1 = hard error +int UblkDevice::claim_instance(const std::string &image_root, const std::string &inst, + std::string &cache_root) { + std::string root = image_root + "/" + inst; + if (mkdir_p(root + "/registry_cache", 0755) != 0 || + mkdir_p(root + "/gzip_cache", 0755) != 0) { + fprintf(stderr, "overlaybd-ublk: cannot create cache dir %s: %s\n", root.c_str(), + strerror(errno)); + return -1; + } + int r = lock_dir(root, cache_lock_fd_); + if (r != 0) + return r; cache_root = root; return 0; } @@ -470,8 +499,8 @@ int UblkDevice::claim_instance(const std::string &image_root, const std::string int UblkDevice::setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_root) { char resolved[PATH_MAX]; if (realpath(opts.image_config_path.c_str(), resolved) == nullptr) { - fprintf(stderr, "overlaybd-ublk: realpath(%s) failed: %s\n", - opts.image_config_path.c_str(), strerror(errno)); + last_error_ = "image config " + opts.image_config_path + ": " + strerror(errno); + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); return -1; } @@ -496,28 +525,38 @@ int UblkDevice::setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_ if (!opts.instance_id.empty()) { if (writable) { - fprintf(stderr, "overlaybd-ublk: --instance-id is not allowed for a " - "writable image (it can only be mounted once)\n"); + last_error_ = "--instance-id is not allowed for a writable image " + "(it can only be mounted once)"; + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); return -1; } if (!valid_instance_id(opts.instance_id)) { - fprintf(stderr, "overlaybd-ublk: invalid --instance-id (allowed: " - "[A-Za-z0-9._-], max 64 chars)\n"); + last_error_ = "invalid --instance-id (allowed: [A-Za-z0-9._-], " + "max 64 chars)"; + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); return -1; } int r = claim_instance(image_root, opts.instance_id, cache_root); - if (r == -2) - fprintf(stderr, "overlaybd-ublk: instance '%s' of this image is " - "already running\n", - opts.instance_id.c_str()); + if (r == -2) { + last_error_ = "instance '" + opts.instance_id + + "' of this image is already running"; + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); + } else if (r == -1) { + last_error_ = "cannot create cache directories under " + image_root; + } return r == 0 ? 0 : -1; } if (writable) { int r = claim_instance(image_root, "inst0", cache_root); - if (r == -2) - fprintf(stderr, "overlaybd-ublk: writable image already mounted " - "(concurrent RW mounts would corrupt the upper layer)\n"); + if (r == -2) { + last_error_ = "writable image already mounted by another daemon " + "or CLI process (concurrent RW mounts would corrupt " + "the upper layer)"; + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); + } else if (r == -1) { + last_error_ = "cannot create cache directories under " + image_root; + } return r == 0 ? 0 : -1; } @@ -525,11 +564,13 @@ int UblkDevice::setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_ int r = claim_instance(image_root, "inst" + std::to_string(i), cache_root); if (r == 0) return 0; - if (r == -1) + if (r == -1) { + last_error_ = "cannot create cache directories under " + image_root; return -1; + } } - fprintf(stderr, "overlaybd-ublk: all 64 cache instance slots of this image " - "are busy\n"); + last_error_ = "all 64 cache instance slots of this image are busy"; + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); return -1; } @@ -539,15 +580,24 @@ int UblkDevice::init_service(const UblkDeviceOpts &opts) { if (setup_cache_root(opts, cache_root) != 0) return -1; std::string patched_config; // temp file, removed after service init - if (make_patched_service_config(opts, cache_root, patched_config) != 0) + if (make_patched_service_config(opts.service_config_path, cache_root, + patched_config) != 0) { + last_error_ = "cannot read or patch the service config"; return -1; + } imgservice_ = create_image_service(patched_config.c_str()); - unlink(patched_config.c_str()); // config parsed, temp copy no longer needed if (imgservice_ == nullptr) { + unlink(patched_config.c_str()); + last_error_ = "failed to create image service (bad service config?)"; LOG_ERROR("failed to create image service"); return -1; } + // keep the patched copy until teardown: create_image_file re-parses the + // service config path later (global default download section); deleting + // it here silently drops those defaults (seen as "default download + // config parse failed" warnings) + patched_config_path_ = patched_config; owns_service_ = true; // per-device log: redirect after create_image_service (which applied the // shared logPath from the global config); reuse the global size/rotation @@ -583,11 +633,75 @@ void UblkDevice::cleanup_failed_start(bool dev_added) { dev_id_ = -1; } +int ublk_daemon_setup_cache(const std::string &cache_base, + const std::string &service_config_path, + std::string &patched_config, int &lock_fd) { + std::string base = + cache_base.empty() ? "/opt/overlaybd/ublk_cache" : cache_base; + std::string daemon_root = base + "/daemon"; + // "daemon" can never collide with a CLI image key (16 hex chars) + if (mkdir_p(daemon_root + "/registry_cache", 0755) != 0 || + mkdir_p(daemon_root + "/gzip_cache", 0755) != 0) { + fprintf(stderr, "overlaybd-ublkd: cannot create cache dir %s: %s\n", + daemon_root.c_str(), strerror(errno)); + return -1; + } + int r = lock_dir(daemon_root, lock_fd); + if (r == -2) { + fprintf(stderr, + "overlaybd-ublkd: cache dir %s is held by another daemon; " + "run a second instance with a different --cache-dir (and " + "--socket-path)\n", + daemon_root.c_str()); + return -1; + } + if (r != 0) + return -1; + return make_patched_service_config(service_config_path, daemon_root, + patched_config); +} + +// Daemon path: cross-process exclusion for writable images. The daemon +// shares one cache tree, so it takes no per-image cache instance -- but a +// writable image mounted here AND by a single-device CLI process would +// still corrupt the upper layer. Reuse the ADR-0004 lock file +// (//inst0/.lock) as lock-only, no cache subdirs, so both +// worlds exclude each other as long as they share the cache base. +int UblkDevice::acquire_rw_image_lock(const UblkDeviceOpts &opts) { + char resolved[PATH_MAX]; + if (realpath(opts.image_config_path.c_str(), resolved) == nullptr) { + LOG_ERROR("realpath(`) failed: `", opts.image_config_path.c_str(), + strerror(errno)); + return -1; + } + std::ifstream in(resolved); + std::string image_json((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + if (!ublk_config_has_upper(image_json)) + return 0; // read-only: shared cache, in-process locks suffice + + std::string base = + opts.cache_dir.empty() ? "/opt/overlaybd/ublk_cache" : opts.cache_dir; + std::string inst0 = base + "/" + ublk_image_cache_key(resolved) + "/inst0"; + int r = lock_dir(inst0, cache_lock_fd_); + if (r == -2) { + LOG_ERROR("writable image ` already mounted (another daemon or CLI " + "process holds `/.lock)", + resolved, inst0.c_str()); + return -1; + } + return r == 0 ? 0 : -1; +} + int UblkDevice::start(const UblkDeviceOpts &opts) { if (imgservice_ == nullptr) { LOG_ERROR("no ImageService bound (daemon must inject one)"); return -1; } + // daemon path: writable images need cross-process exclusion before the + // upper layer is opened (CLI takes its lock in setup_cache_root instead) + if (!owns_service_ && acquire_rw_image_lock(opts) != 0) + return -1; // registration tag: ImageService keys its device table by this string, // so it must be unique. CLI: pid alone (one device per process, matches // the log tag). Daemon: many devices share the pid, append a sequence -- @@ -602,6 +716,8 @@ int UblkDevice::start(const UblkDeviceOpts &opts) { // init_tgt, and a broken config must fail the add command, not the device file_ = imgservice_->create_image_file(opts.image_config_path.c_str(), dev_tag); if (file_ == nullptr) { + last_error_ = "failed to open image " + opts.image_config_path + + " (bad image config or unreachable layer files)"; LOG_ERROR("failed to open image `", opts.image_config_path.c_str()); return -1; } @@ -633,6 +749,8 @@ int UblkDevice::start(const UblkDeviceOpts &opts) { int ret = ublksrv_ctrl_add_dev(ctrl_dev_); if (ret < 0) { LOG_ERROR("ublksrv_ctrl_add_dev failed: `", ret); + last_error_ = "UBLK ADD_DEV failed (" + std::to_string(ret) + + "); requested dev id busy or kernel refused"; cleanup_failed_start(false); return -1; } @@ -658,6 +776,7 @@ int UblkDevice::start(const UblkDeviceOpts &opts) { queue_thread_.join(); ublksrv_dev_deinit(dev_); dev_ = nullptr; + last_error_ = "queue ring init failed (see overlaybd log / syslog)"; cleanup_failed_start(true); return -1; } @@ -709,6 +828,10 @@ void UblkDevice::teardown() { if (owns_service_) delete imgservice_; imgservice_ = nullptr; + if (!patched_config_path_.empty()) { + unlink(patched_config_path_.c_str()); + patched_config_path_.clear(); + } } int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { @@ -716,9 +839,12 @@ int UblkDevice::run(const UblkDeviceOpts &opts, int ready_fd) { return 1; mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); // pidfile dir for libublksrv - if (init_service(opts) != 0) + if (init_service(opts) != 0) { + notify_error(ready_fd, last_error_); return 1; + } if (start(opts) != 0) { + notify_error(ready_fd, last_error_); teardown(); return 1; } diff --git a/src/ublk/ublk_device.h b/src/ublk/ublk_device.h index 257a18fb..91f9f431 100644 --- a/src/ublk/ublk_device.h +++ b/src/ublk/ublk_device.h @@ -56,6 +56,18 @@ int ublk_check_control_dev(); // exit code. int run_ublk_device(const UblkDeviceOpts &opts, int ready_fd); +// Daemon-side cache setup (ADR-0006 M2-1). All devices of a daemon share +// one cache tree /daemon/{registry_cache,gzip_cache}: creates it, +// takes an exclusive flock on /daemon/.lock (a second daemon on the +// same base is refused), and writes a service config copy patched to that +// tree (the cacheDir of the original service config is deliberately +// ignored -- single-valued rule, and the default keeps clear of tcmu's +// /opt/overlaybd/registry_cache). Returns 0 and the lock fd (held for the +// daemon's lifetime) or -1 with a message on stderr. +int ublk_daemon_setup_cache(const std::string &cache_base, + const std::string &service_config_path, + std::string &patched_config, int &lock_fd); + class ImageService; class ImageFile; class ImageFileTarget; @@ -105,6 +117,7 @@ class UblkDevice { int init_service(const UblkDeviceOpts &opts); // CLI path only void cleanup_failed_start(bool dev_added); // no half-created device stays int setup_cache_root(const UblkDeviceOpts &opts, std::string &cache_root); + int acquire_rw_image_lock(const UblkDeviceOpts &opts); // daemon path only int claim_instance(const std::string &image_root, const std::string &inst, std::string &cache_root); void set_dev_parameters(); @@ -121,6 +134,13 @@ class UblkDevice { std::atomic queue_exited_{false}; unsigned ring_flags_ = 0; // queue io_uring flags, probed in start() int cache_lock_fd_ = -1; // held for the device's lifetime + // human-readable reason of a failed bring-up; relayed to the add + // command's console through the ready pipe (daemonized childs lose + // stderr, and alog is not yet redirected during early setup) + std::string last_error_; + // CLI path: patched service config path, kept alive until teardown + // (create_image_file re-parses it for the global download defaults) + std::string patched_config_path_; int dev_id_ = -1; bool owns_service_ = true; bool torn_down_ = false; diff --git a/src/ublk/ublkd_main.cpp b/src/ublk/ublkd_main.cpp index dc8ea787..4191fd94 100644 --- a/src/ublk/ublkd_main.cpp +++ b/src/ublk/ublkd_main.cpp @@ -33,9 +33,11 @@ #include #include +#include #include #include #include +#include #include #include @@ -47,7 +49,8 @@ class UblkdServer : public photon::net::http::HTTPHandler { public: - UblkdServer(ImageService *service) : service_(service) { + UblkdServer(ImageService *service, std::string cache_base) + : service_(service), cache_base_(std::move(cache_base)) { } ~UblkdServer() { @@ -55,10 +58,10 @@ class UblkdServer : public photon::net::http::HTTPHandler { // total teardown time is about the slowest device instead of the sum // (the bounded-DEL fallback costs 2s per device on backport kernels) for (auto &kv : devices_) - kv.second->stop(); + kv.second.dev->stop(); for (auto &kv : devices_) { - kv.second->wait(); - kv.second->teardown(); + kv.second.dev->wait(); + kv.second.dev->teardown(); } devices_.clear(); } @@ -117,21 +120,42 @@ class UblkdServer : public photon::net::http::HTTPHandler { msg = ublkd_msg_error(err); return; } + // Per-image in-flight guard (M2-2). Note: since M2-1 the same-image + // RW race is already closed by the inst0 flock (LOCK_EX excludes + // between fds of one process too); this guard is UX -- reject the + // duplicate immediately instead of failing it after seconds of work. + char resolved[PATH_MAX]; + if (realpath(areq.config.c_str(), resolved) == nullptr) { + code = 400; + msg = ublkd_msg_error("image config " + areq.config + ": " + + strerror(errno)); + return; + } + if (!adds_in_flight_.insert(resolved).second) { + code = 409; + msg = ublkd_msg_error("an add of this image is already in progress"); + return; + } + auto dev = std::make_unique(service_); UblkDeviceOpts opts; opts.image_config_path = areq.config; opts.dev_id = areq.dev_id; opts.queue_depth = areq.queue_depth; - // M1: instance_id reserved; cache patching/slots are skipped in the - // injected-service path (shared cache, in-process locks -- ADR-0006) - if (dev->start(opts) != 0) { + // cache tree is shared daemon-wide; cache_dir here only tells the + // device where the cross-process RW image locks live (M2-1) + opts.cache_dir = cache_base_; + int ret = dev->start(opts); + adds_in_flight_.erase(resolved); + if (ret != 0) { code = 500; msg = ublkd_msg_error("device failed to start (see daemon log)"); return; } int id = dev->dev_id(); - configs_[id] = areq.config; - devices_[id] = std::move(dev); + auto &entry = devices_[id]; + entry.dev = std::move(dev); + entry.config = areq.config; code = 200; msg = ublkd_msg_added(id, "/dev/ublkb" + std::to_string(id)); LOG_INFO("ublkd: added /dev/ublkb` image `", id, areq.config.c_str()); @@ -151,13 +175,20 @@ class UblkdServer : public photon::net::http::HTTPHandler { msg = ublkd_msg_error("no such device: " + std::to_string(id)); return; } - // M1: synchronous del -- stop, drain, delete, then reply (serial - // control plane; concurrent del/list responsiveness is M2) - it->second->stop(); - it->second->wait(); - it->second->teardown(); - devices_.erase(it); - configs_.erase(id); + // Deletion state machine (M2-2): del is synchronous and yields while + // draining, so a concurrent del of the same id would otherwise race + // on the map iterator (erase invalidates the other handler's it). + if (it->second.stopping) { + code = 409; + msg = ublkd_msg_error("delete of device " + std::to_string(id) + + " is already in progress"); + return; + } + it->second.stopping = true; + it->second.dev->stop(); + it->second.dev->wait(); + it->second.dev->teardown(); + devices_.erase(id); // re-lookup by key: `it` may not survive yields code = 200; msg = ublkd_msg_ok(); LOG_INFO("ublkd: deleted /dev/ublkb`", id); @@ -169,9 +200,9 @@ class UblkdServer : public photon::net::http::HTTPHandler { UblkdDeviceInfo info; info.dev_id = kv.first; info.dev_path = "/dev/ublkb" + std::to_string(kv.first); - info.config = configs_[kv.first]; - info.writable = kv.second->writable(); - info.state = "running"; // M1: del is synchronous, no stopping state + info.config = kv.second.config; + info.writable = kv.second.dev->writable(); + info.state = kv.second.stopping ? "stopping" : "running"; infos.push_back(std::move(info)); } code = 200; @@ -179,8 +210,15 @@ class UblkdServer : public photon::net::http::HTTPHandler { } ImageService *service_; - std::map> devices_; - std::map configs_; // dev_id -> image config path + std::string cache_base_; + struct DevEntry { + std::unique_ptr dev; + std::string config; + bool stopping = false; + }; + std::map devices_; + // realpath'd image configs with an add in progress (UX guard, see above) + std::set adds_in_flight_; }; // entry-layer signal routing (same pattern & rationale as cli g_signal_device) @@ -195,6 +233,7 @@ static void sigterm_handler(int signal) { int main(int argc, char **argv) { std::string socket_path = UBLKD_DEFAULT_SOCK; std::string service_config; + std::string cache_dir; CLI::App app{"overlaybd-ublkd: centralized ublk device manager daemon " "(all devices in one process, shared ImageService)"}; @@ -202,6 +241,10 @@ int main(int argc, char **argv) { "unix socket for the control API (default " UBLKD_DEFAULT_SOCK ")"); app.add_option("--service-config", service_config, "overlaybd service config (default /etc/overlaybd/overlaybd.json)"); + app.add_option("--cache-dir", cache_dir, + "cache base directory (default /opt/overlaybd/ublk_cache); " + "the daemon uses /daemon/ for all devices and ignores " + "the service config's cacheDir"); CLI11_PARSE(app, argc, argv); if (ublk_check_control_dev() != 0) @@ -218,19 +261,32 @@ int main(int argc, char **argv) { close(devnull); } + // shared cache tree + daemon flock + patched service config (M2-1); + // plain fs/flock work, safe before photon::init + std::string patched_config; + int cache_lock_fd = -1; // held until exit; kernel releases on any death + if (ublk_daemon_setup_cache(cache_dir, service_config, patched_config, + cache_lock_fd) != 0) + return 1; + photon::init(photon::INIT_EVENT_DEFAULT, photon::INIT_IO_DEFAULT); photon::block_all_signal(); photon::sync_signal(SIGTERM, &sigterm_handler); photon::sync_signal(SIGINT, &sigterm_handler); - ImageService *service = - create_image_service(service_config.empty() ? nullptr : service_config.c_str()); + ImageService *service = create_image_service(patched_config.c_str()); if (service == nullptr) { + unlink(patched_config.c_str()); fprintf(stderr, "overlaybd-ublkd: failed to create image service\n"); return 1; } + // patched copy stays for the daemon's lifetime: create_image_file + // re-parses this path for the global default download section + // photon LOG prints adjacent string literals with quotes, keep one literal + LOG_INFO("cache: `/daemon (service config cacheDir is overridden, see --cache-dir)", + cache_dir.empty() ? "/opt/overlaybd/ublk_cache" : cache_dir.c_str()); - auto *server = new UblkdServer(service); + auto *server = new UblkdServer(service, cache_dir); g_shutdown_flag = &server->shutdown_requested; auto *sock = photon::net::new_uds_server(true /* autoremove */); @@ -257,6 +313,7 @@ int main(int argc, char **argv) { delete server; // parallel-stops and tears down every device delete sock; delete service; + unlink(patched_config.c_str()); photon::fini(); return 0; } From 277694f63d9d92ab6ca06157a85049353435cc62 Mon Sep 17 00:00:00 2001 From: haolianglh Date: Tue, 28 Jul 2026 15:59:14 +0800 Subject: [PATCH 6/8] Add online resize and systemd unit to ublkd Add POST /v1/resize for growing a writable image online: the image layer goes through ImageFile::resize() in-process (optionally growing the ext4 inside), then the kernel is told the new capacity with UBLK_U_CMD_UPDATE_SIZE. libublksrv v1.7 has no wrapper for that command and its generic sender is private, so the uring_cmd is sent on a private throwaway SQE128 ring against /dev/ublk-control, assembled the same way as the library does. Support is negotiated: UBLK_F_UPDATE_SIZE is probed once via GET_FEATURES and requested at ADD_DEV, since the kernel only accepts UPDATE_SIZE on devices created with the flag. Only growing is allowed and only for writable images; kernels without the feature get a clear 501. The error paths (feature missing, read-only device, unknown device, bad parameters) are runtime verified; the success path needs a mainline >= 6.11 kernel and is covered by the pending mainline regression. Also ship a systemd unit template for the daemon (generous TimeoutStopSec: deletions may take seconds per device on kernels without DEL_DEV_ASYNC, and killing a flushing daemon risks a writeback deadlock) and document the daemon in the README. Signed-off-by: haolianglh --- README.md | 22 +++++ src/example_config/overlaybd-ublkd.service | 30 ++++++ src/ublk/CMakeLists.txt | 2 + src/ublk/test/ublk_dispatch_test.cpp | 24 +++++ src/ublk/ublk_device.cpp | 107 ++++++++++++++++++++- src/ublk/ublk_device.h | 7 ++ src/ublk/ublkd_main.cpp | 32 ++++++ src/ublk/ublkd_protocol.cpp | 30 ++++++ src/ublk/ublkd_protocol.h | 10 ++ 9 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/example_config/overlaybd-ublkd.service diff --git a/README.md b/README.md index 59a79e1b..d688ecab 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,28 @@ own log file via `add --log-path ...` (recommended when running multiple devices; daemons otherwise share the global log file and are distinguished by a `ublk-` tag). +For hosting many devices in one process there is also `overlaybd-ublkd`, a +centralized daemon sharing a single ImageService (and thus one cache tree, +`/daemon/` under `--cache-dir`, guarded by an exclusive lock). Its +control API is HTTP over a root-only unix socket, debuggable with curl: + +```bash +sudo overlaybd-ublkd & # or: systemctl start overlaybd-ublkd +SOCK=/var/run/overlaybd-ublk/ublkd.sock +curl --unix-socket $SOCK -X POST -d '{"config":"/path/config.v1.json"}' http://d/v1/add +curl --unix-socket $SOCK http://d/v1/list +curl --unix-socket $SOCK -X POST -d '{"dev_id":0}' http://d/v1/del +curl --unix-socket $SOCK -X POST -d '{"dev_id":0,"size_gb":50,"resize_fs":true}' http://d/v1/resize +curl --unix-socket $SOCK -X POST http://d/v1/shutdown # stops all devices +``` + +`add` replies once the device is usable; `del` replies after full teardown; +online `resize` (grow only, writable images) additionally needs a kernel +with `UBLK_F_UPDATE_SIZE` (mainline >= 6.11). Writable images are mounted +exclusively across the daemon and CLI processes; devices owned by the +daemon must be deleted through its API (the CLI `del` refuses them). A +systemd unit template is installed at /opt/overlaybd/overlaybd-ublkd.service. + Notes: * Always unmount filesystems on `/dev/ublkbN` before `del` (or before stopping diff --git a/src/example_config/overlaybd-ublkd.service b/src/example_config/overlaybd-ublkd.service new file mode 100644 index 00000000..0412e303 --- /dev/null +++ b/src/example_config/overlaybd-ublkd.service @@ -0,0 +1,30 @@ +[Unit] +Description=overlaybd-ublkd service (centralized ublk device manager) +After=network.target local-fs.target +Before=shutdown.target +DefaultDependencies=no +Conflicts=shutdown.target + +[Service] +LimitNOFILE=1048576 +LimitCORE=infinity +Type=simple + +ExecStartPre=/sbin/modprobe ublk_drv + +ExecStart=/opt/overlaybd/bin/overlaybd-ublkd + +# Devices are stopped in parallel on shutdown, but each deletion may take +# ~2s on kernels without DEL_DEV_ASYNC. Give teardown enough room: killing +# a daemon that is still flushing dirty pages risks a writeback deadlock +# (see the ublk notes in README). +TimeoutStopSec=60 + +GuessMainPID=no +Restart=on-failure +RestartSec=1s +KillMode=process +OOMScoreAdjust=-999 + +[Install] +WantedBy=multi-user.target diff --git a/src/ublk/CMakeLists.txt b/src/ublk/CMakeLists.txt index 3ad6e9ce..ef6b87c6 100644 --- a/src/ublk/CMakeLists.txt +++ b/src/ublk/CMakeLists.txt @@ -45,6 +45,8 @@ target_link_libraries(overlaybd-ublkd ${AIO_LIBRARIES} ) install(TARGETS overlaybd-ublkd DESTINATION /opt/overlaybd/bin) +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/../example_config/overlaybd-ublkd.service + DESTINATION /opt/overlaybd/) if (BUILD_TESTING) add_subdirectory(test) diff --git a/src/ublk/test/ublk_dispatch_test.cpp b/src/ublk/test/ublk_dispatch_test.cpp index 54f53177..f5aba38b 100644 --- a/src/ublk/test/ublk_dispatch_test.cpp +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -340,6 +340,30 @@ TEST(ublkd_protocol, parse_del) { EXPECT_EQ(ublkd_parse_del(R"({})", id, err), -1); } +TEST(ublkd_protocol, parse_resize) { + UblkdResizeRequest req; + std::string err; + ASSERT_EQ(ublkd_parse_resize( + R"({"dev_id":2,"size_gb":50,"resize_fs":true})", req, err), + 0); + EXPECT_EQ(req.dev_id, 2); + EXPECT_EQ(req.size_gb, 50UL); + EXPECT_TRUE(req.resize_fs); + + UblkdResizeRequest defaults; + ASSERT_EQ(ublkd_parse_resize(R"({"dev_id":0,"size_gb":1})", defaults, err), 0); + EXPECT_FALSE(defaults.resize_fs); // default: block device only + + // malformed: no dev_id / no size / zero / absurd size / bad type + EXPECT_EQ(ublkd_parse_resize(R"({"size_gb":10})", req, err), -1); + EXPECT_EQ(ublkd_parse_resize(R"({"dev_id":0})", req, err), -1); + EXPECT_EQ(ublkd_parse_resize(R"({"dev_id":0,"size_gb":0})", req, err), -1); + EXPECT_EQ(ublkd_parse_resize(R"({"dev_id":0,"size_gb":99999999})", req, err), -1); + EXPECT_EQ(ublkd_parse_resize(R"({"dev_id":0,"size_gb":1,"resize_fs":"y"})", req, + err), + -1); +} + TEST(ublkd_protocol, responses_roundtrip) { EXPECT_EQ(ublkd_msg_ok(), R"({"ok":true})"); EXPECT_NE(ublkd_msg_error("boom").find(R"("ok":false)"), std::string::npos); diff --git a/src/ublk/ublk_device.cpp b/src/ublk/ublk_device.cpp index f7741773..44fce859 100644 --- a/src/ublk/ublk_device.cpp +++ b/src/ublk/ublk_device.cpp @@ -693,6 +693,109 @@ int UblkDevice::acquire_rw_image_lock(const UblkDeviceOpts &opts) { return r == 0 ? 0 : -1; } +// --------------------------------------------------------------------------- +// raw ublk control commands (M2-3) +// --------------------------------------------------------------------------- +// libublksrv v1.7 wraps GET_FEATURES but not UPDATE_SIZE, and its generic +// sender is private -- so send the uring_cmd ourselves on a throwaway ring +// against /dev/ublk-control, assembled exactly like ublksrv_ctrl_init_cmd +// (SQE128, payload at sqe->addr3, cmd op at sqe->off). Resize is rare, the +// per-call ring setup cost is irrelevant. +static int ublk_raw_ctrl(uint32_t cmd_op, int dev_id, uint64_t data0, void *buf, + uint16_t len) { + int cfd = open(UBLK_CONTROL_DEV, O_RDWR); + if (cfd < 0) + return -errno; + struct io_uring ring; + struct io_uring_params p; + memset(&p, 0, sizeof(p)); + p.flags = IORING_SETUP_SQE128; + int ret = io_uring_queue_init_params(4, &ring, &p); + if (ret < 0) { + close(cfd); + return ret; + } + struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); + memset(sqe, 0, 128); // SQE128 slot + sqe->fd = cfd; + sqe->opcode = IORING_OP_URING_CMD; + *(uint32_t *)&sqe->off = cmd_op; // ublksrv_set_sqe_cmd_op equivalent + struct ublksrv_ctrl_cmd *cmd = (struct ublksrv_ctrl_cmd *)&sqe->addr3; + cmd->dev_id = dev_id; + cmd->queue_id = (uint16_t)-1; + cmd->data[0] = data0; + cmd->addr = (uint64_t)buf; + cmd->len = len; + do { + ret = io_uring_submit_and_wait(&ring, 1); + } while (ret == -EINTR); + if (ret >= 0) { + struct io_uring_cqe *cqe = nullptr; + ret = io_uring_peek_cqe(&ring, &cqe); + if (ret == 0) { + ret = cqe->res; + io_uring_cqe_seen(&ring, cqe); + } + } + io_uring_queue_exit(&ring); + close(cfd); + return ret; +} + +static int ublk_raw_ctrl_cmd(uint32_t cmd_op, int dev_id, uint64_t data0) { + return ublk_raw_ctrl(cmd_op, dev_id, data0, nullptr, 0); +} + +static int ublk_raw_ctrl_cmd_buf(uint32_t cmd_op, int dev_id, void *buf, uint16_t len) { + return ublk_raw_ctrl(cmd_op, dev_id, 0, buf, len); +} + +// UBLK_F_UPDATE_SIZE support, probed once per process (GET_FEATURES itself +// is 6.5+; on kernels without it the probe fails -> no support) +static bool kernel_supports_update_size() { + static int cached = -1; + if (cached < 0) { + uint64_t features = 0; + int r = ublk_raw_ctrl_cmd_buf(UBLK_U_CMD_GET_FEATURES, -1, &features, + sizeof(features)); + cached = (r >= 0 && (features & UBLK_F_UPDATE_SIZE)) ? 1 : 0; + if (!cached) + LOG_WARN("UBLK_F_UPDATE_SIZE unavailable (probe `), online resize disabled", r); + } + return cached == 1; +} + +int UblkDevice::resize(uint64_t new_size_bytes, bool resize_fs, std::string &err) { + if (!writable()) { + err = "device is read-only; resize applies to writable images only"; + return -3; + } + if (new_size_bytes <= image_size()) { + err = "new size must be larger than the current " + + std::to_string(image_size() >> 30) + " GiB (only growing is supported)"; + return -3; + } + if (!kernel_supports_update_size()) { + err = "kernel lacks UBLK_F_UPDATE_SIZE (mainline >= 6.11 required)"; + return -2; + } + // image layer first (and ext4 if asked): on failure nothing changed + if (file_->resize(new_size_bytes, resize_fs) != 0) { + err = "image resize failed (see the overlaybd log)"; + return -1; + } + // then tell the kernel; sectors of 512 + int r = ublk_raw_ctrl_cmd(UBLK_U_CMD_UPDATE_SIZE, dev_id_, new_size_bytes >> 9); + if (r < 0) { + err = "UBLK_U_CMD_UPDATE_SIZE failed (" + std::to_string(r) + + "); image already grown, kernel size unchanged"; + return -1; + } + LOG_INFO("resized /dev/ublkb` to ` bytes (resize_fs=`)", dev_id_, new_size_bytes, + resize_fs); + return 0; +} + int UblkDevice::start(const UblkDeviceOpts &opts) { if (imgservice_ == nullptr) { LOG_ERROR("no ImageService bound (daemon must inject one)"); @@ -735,7 +838,9 @@ int UblkDevice::start(const UblkDeviceOpts &opts) { data.tgt_type = "overlaybd"; data.tgt_ops = &obd_tgt_type; data.run_dir = OVERLAYBD_UBLK_RUN_DIR; - data.flags = 0; + // request online-resize capability when the kernel has it (the + // UPDATE_SIZE command only works on devices created with the flag) + data.flags = kernel_supports_update_size() ? UBLK_F_UPDATE_SIZE : 0; ctrl_dev_ = ublksrv_ctrl_init(&data); if (ctrl_dev_ == nullptr) { diff --git a/src/ublk/ublk_device.h b/src/ublk/ublk_device.h index 91f9f431..8aa3854b 100644 --- a/src/ublk/ublk_device.h +++ b/src/ublk/ublk_device.h @@ -107,6 +107,13 @@ class UblkDevice { void stop(); // request stop; safe from a photon signal coroutine void teardown(); + // Online grow (M2-3): image layer (ImageFile::resize, optionally ext4) + // + kernel layer (UBLK_U_CMD_UPDATE_SIZE). Writable images only; the + // kernel must support UBLK_F_UPDATE_SIZE (mainline >= 6.11). + // Returns 0 = ok; -1 = internal error; -2 = kernel lacks the feature; + // -3 = invalid request (read-only device / shrink). err says why. + int resize(uint64_t new_size_bytes, bool resize_fs, std::string &err); + int dev_id() const { return dev_id_; } diff --git a/src/ublk/ublkd_main.cpp b/src/ublk/ublkd_main.cpp index 4191fd94..452ac676 100644 --- a/src/ublk/ublkd_main.cpp +++ b/src/ublk/ublkd_main.cpp @@ -77,6 +77,8 @@ class UblkdServer : public photon::net::http::HTTPHandler { handle_add(body, code, msg); } else if (target == "/v1/del" && req.verb() == photon::net::http::Verb::POST) { handle_del(body, code, msg); + } else if (target == "/v1/resize" && req.verb() == photon::net::http::Verb::POST) { + handle_resize(body, code, msg); } else if (target == "/v1/list") { handle_list(code, msg); } else if (target == "/v1/ping") { @@ -194,6 +196,36 @@ class UblkdServer : public photon::net::http::HTTPHandler { LOG_INFO("ublkd: deleted /dev/ublkb`", id); } + void handle_resize(const std::string &body, int &code, std::string &msg) { + UblkdResizeRequest rreq; + std::string err; + if (ublkd_parse_resize(body, rreq, err) != 0) { + code = 400; + msg = ublkd_msg_error(err); + return; + } + auto it = devices_.find(rreq.dev_id); + if (it == devices_.end()) { + code = 404; + msg = ublkd_msg_error("no such device: " + std::to_string(rreq.dev_id)); + return; + } + if (it->second.stopping) { + code = 409; + msg = ublkd_msg_error("device is being deleted"); + return; + } + int r = it->second.dev->resize(rreq.size_gb << 30, rreq.resize_fs, err); + if (r == 0) { + code = 200; + msg = ublkd_msg_ok(); + LOG_INFO("ublkd: resized dev ` to ` GB", rreq.dev_id, rreq.size_gb); + } else { + code = (r == -2) ? 501 : (r == -3) ? 400 : 500; + msg = ublkd_msg_error(err); + } + } + void handle_list(int &code, std::string &msg) { std::vector infos; for (auto &kv : devices_) { diff --git a/src/ublk/ublkd_protocol.cpp b/src/ublk/ublkd_protocol.cpp index a97a87ae..fdb0fe53 100644 --- a/src/ublk/ublkd_protocol.cpp +++ b/src/ublk/ublkd_protocol.cpp @@ -72,6 +72,36 @@ int ublkd_parse_del(const std::string &body, int &dev_id, std::string &err) { return 0; } +int ublkd_parse_resize(const std::string &body, UblkdResizeRequest &req, + std::string &err) { + rapidjson::Document doc; + doc.Parse(body.c_str()); + if (doc.HasParseError() || !doc.IsObject()) { + err = "request body is not a JSON object"; + return -1; + } + if (!doc.HasMember("dev_id") || !doc["dev_id"].IsInt() || doc["dev_id"].GetInt() < 0) { + err = "missing or invalid field: dev_id"; + return -1; + } + req.dev_id = doc["dev_id"].GetInt(); + // 1..1M GB: large enough for any real image, small enough to catch typos + if (!doc.HasMember("size_gb") || !doc["size_gb"].IsUint64() || + doc["size_gb"].GetUint64() < 1 || doc["size_gb"].GetUint64() > (1ULL << 20)) { + err = "size_gb must be an integer in [1, 1048576]"; + return -1; + } + req.size_gb = doc["size_gb"].GetUint64(); + if (doc.HasMember("resize_fs")) { + if (!doc["resize_fs"].IsBool()) { + err = "resize_fs must be a boolean"; + return -1; + } + req.resize_fs = doc["resize_fs"].GetBool(); + } + return 0; +} + static std::string dump(const rapidjson::Document &doc) { rapidjson::StringBuffer sb; rapidjson::Writer writer(sb); diff --git a/src/ublk/ublkd_protocol.h b/src/ublk/ublkd_protocol.h index fe1b0f4b..d24e6002 100644 --- a/src/ublk/ublkd_protocol.h +++ b/src/ublk/ublkd_protocol.h @@ -45,6 +45,16 @@ int ublkd_parse_add(const std::string &body, UblkdAddRequest &req, std::string & // parse POST /v1/del body ({"dev_id":N}); 0 = ok, -1 = malformed int ublkd_parse_del(const std::string &body, int &dev_id, std::string &err); +struct UblkdResizeRequest { + int dev_id = -1; + uint64_t size_gb = 0; // new virtual size, must grow + bool resize_fs = false; // also grow the ext4 inside +}; + +// parse POST /v1/resize body; 0 = ok, -1 = malformed +int ublkd_parse_resize(const std::string &body, UblkdResizeRequest &req, + std::string &err); + // response builders (single-line JSON) std::string ublkd_msg_ok(); // {"ok":true} std::string ublkd_msg_error(const std::string &error); // {"ok":false,...} From a9b72920dd3adc6310c6dfc87986274367bdadc6 Mon Sep 17 00:00:00 2001 From: haolianglh Date: Tue, 28 Jul 2026 20:23:36 +0800 Subject: [PATCH 7/8] Externalize ublk del and reclaim orphan devices Rework `del` to drive the kernel directly instead of signaling the owner process. STOP_DEV/DEL_DEV are per-dev_id control commands that any root process may send through /dev/ublk-control, so deletion no longer depends on a pidfile and works on orphans whose owner was killed -9. GET_DEV_INFO identifies the owner as the kernel sees it; a live single-device owner is stopped externally and drains without self-deleting (teardown skips DEL when the stop was external), then del issues DEL. Devices owned by overlaybd-ublkd are still refused with a pointer to the daemon API. This also cures the writeback-deadlock window of the old path: del no longer relies on the owner tearing itself down under SIGTERM, and a normal del now takes ~0.2s instead of ~2.2s (it no longer hits the bounded self-DEL fallback). `list` now takes the kernel as source of truth: it scans /sys/block and /dev/ublkc for devices, reports each as running / [ublkd-managed] / ORPHAN, and additionally flags stopped-but-not-deleted residuals (this kernel auto-stops a device when its owner dies but does not free the id) and stale pidfiles. Such residuals are reclaimable with the same `del`, retiring the old rmmod workaround. Add `del --force` for an unresponsive owner: it reads the owner pid from the pidfile (never from the kernel -- once STOP_DEV was sent to a device whose owner is frozen, every further control command blocks forever, so --force must not touch the control plane before the owner is gone), SIGCONTs then SIGKILLs it, and only then reclaims the device. Without --force a wedged owner is reported and refused rather than risking a machine hang on backported kernels. Verified on a ublk-capable kernel: orphan reclamation (single-device and daemon), residual listing and cleanup, id reuse after reclaim, --force on a frozen owner, live-daemon refusal, and that the daemon's own del and SIGTERM teardown paths are unchanged. Signed-off-by: haolianglh --- src/ublk/cli.cpp | 5 +- src/ublk/cli.h | 1 + src/ublk/main.cpp | 260 +++++++++++++++++++-------- src/ublk/test/ublk_dispatch_test.cpp | 5 + src/ublk/ublk_device.cpp | 50 +++++- src/ublk/ublk_device.h | 17 ++ 6 files changed, 260 insertions(+), 78 deletions(-) diff --git a/src/ublk/cli.cpp b/src/ublk/cli.cpp index 193d6545..a1b8066c 100644 --- a/src/ublk/cli.cpp +++ b/src/ublk/cli.cpp @@ -46,8 +46,11 @@ int ublk_parse_cli(int argc, char **argv, UblkCliCmd &cmd) { "image multiple times (default: auto slot instN)"); add->add_flag("--foreground", cmd.foreground, "run in foreground (no daemonize)"); - auto *del = app.add_subcommand("del", "stop the daemon serving /dev/ublkbN"); + auto *del = app.add_subcommand("del", "delete /dev/ublkbN (works on orphans too)"); del->add_option("-n,--dev-id", cmd.del_dev_id, "device id")->required(); + del->add_flag("--force", cmd.del_force, + "SIGKILL an owner that does not exit after STOP, then delete " + "(risky on wedged devices, see docs)"); app.add_subcommand("list", "list overlaybd-ublk devices on this host"); diff --git a/src/ublk/cli.h b/src/ublk/cli.h index 94946f1c..fef4cd09 100644 --- a/src/ublk/cli.h +++ b/src/ublk/cli.h @@ -26,6 +26,7 @@ struct UblkCliCmd { bool foreground = false; int del_dev_id = -1; // DEL + bool del_force = false; // DEL: SIGKILL a non-exiting owner, then delete }; // Parse argv into cmd. On success returns 0 with cmd.kind set; on diff --git a/src/ublk/main.cpp b/src/ublk/main.cpp index 7a666d70..4fde6ba6 100644 --- a/src/ublk/main.cpp +++ b/src/ublk/main.cpp @@ -29,21 +29,6 @@ #include #include -static int read_pidfile(int dev_id, pid_t *pid) { - char path[128]; - snprintf(path, sizeof(path), "%s/%d.pid", OVERLAYBD_UBLK_RUN_DIR, dev_id); - FILE *fp = fopen(path, "r"); - if (fp == nullptr) - return -1; - long v = 0; - int n = fscanf(fp, "%ld", &v); - fclose(fp); - if (n != 1 || v <= 0) - return -1; - *pid = (pid_t)v; - return 0; -} - // A pidfile under the run dir may belong to a one-device CLI daemon or to // overlaybd-ublkd (libublksrv writes .pid with the serving process' // pid either way). The distinction matters: SIGTERM to a CLI daemon removes @@ -62,85 +47,210 @@ static bool pid_is_ublkd(pid_t pid) { return strcmp(comm, "overlaybd-ublkd") == 0; } -// del: process == device, so a graceful stop is SIGTERM to the -// daemon, whose signal handler tears the ublk device down before exiting. -static int cmd_del(int dev_id) { - pid_t pid; - if (read_pidfile(dev_id, &pid) != 0) { - fprintf(stderr, "overlaybd-ublk: no pidfile for dev %d under %s\n", dev_id, - OVERLAYBD_UBLK_RUN_DIR); +// del (A2, "del 外部化"): drive the kernel directly instead of signaling the +// owner. STOP/DEL go through /dev/ublk-control by dev_id, so orphans (owner +// killed -9) are deletable and no pidfile is needed. A live CLI owner is +// stopped externally: its queue dies, it drains and exits WITHOUT deleting +// (teardown skips DEL when the stop was not its own), then we DEL here. +static void remove_pidfile(int dev_id) { + char path[128]; + snprintf(path, sizeof(path), "%s/%d.pid", OVERLAYBD_UBLK_RUN_DIR, dev_id); + unlink(path); +} + +// owner pid from the pidfile written by libublksrv. Needed by --force: once +// STOP_DEV has been issued against a device whose owner is frozen, the +// kernel holds that device's control plane and every further ctrl command +// (even GET_DEV_INFO) blocks forever -- so --force must learn the pid +// WITHOUT asking the kernel, kill the owner, and only then touch the +// control plane. Verified the hard way: --force used to hang in +// io_cqring_wait on its very first GET_DEV_INFO. +static pid_t pid_from_pidfile(int dev_id) { + char path[128]; + snprintf(path, sizeof(path), "%s/%d.pid", OVERLAYBD_UBLK_RUN_DIR, dev_id); + FILE *fp = fopen(path, "r"); + if (fp == nullptr) + return -1; + long v = -1; + int n = fscanf(fp, "%ld", &v); + fclose(fp); + return (n == 1 && v > 0) ? (pid_t)v : -1; +} + +static int cmd_del(int dev_id, bool force) { + if (force) { + pid_t owner = pid_from_pidfile(dev_id); + if (owner > 0 && kill(owner, 0) == 0) { + if (pid_is_ublkd(owner)) { + fprintf(stderr, + "overlaybd-ublk: refusing --force on a device of a live " + "overlaybd-ublkd (pid %d); stop the daemon instead\n", + (int)owner); + return 1; + } + // SIGCONT first: a SIGSTOPped owner does not act on a queued + // SIGKILL until it is resumed (observed on a frozen owner) + kill(owner, SIGCONT); + kill(owner, SIGKILL); + int i; + for (i = 0; i < 100 && kill(owner, 0) == 0; i++) + usleep(100 * 1000); + if (i == 100) + fprintf(stderr, "overlaybd-ublk: owner pid %d survived SIGKILL " + "(D state?), trying kernel teardown anyway\n", + (int)owner); + else + fprintf(stderr, "overlaybd-ublk: killed owner pid %d\n", (int)owner); + sleep(1); // let the kernel run its owner-death cleanup + } + } + + UblkKernelDevInfo info; + int r = ublk_kernel_get_dev_info(dev_id, info); + if (r == -ENODEV || r == -ENOENT) { + fprintf(stderr, "overlaybd-ublk: no ublk device %d in the kernel\n", dev_id); + remove_pidfile(dev_id); // stale leftovers return 1; } - if (pid_is_ublkd(pid)) { + if (r < 0) { + fprintf(stderr, "overlaybd-ublk: GET_DEV_INFO(%d) failed: %s\n", dev_id, + strerror(-r)); + return 1; + } + + pid_t pid = info.owner_pid; + bool alive = pid > 0 && kill(pid, 0) == 0; + if (alive && pid_is_ublkd(pid)) { fprintf(stderr, "overlaybd-ublk: /dev/ublkb%d is managed by overlaybd-ublkd " - "(pid %d); SIGTERM would tear down ALL of its devices.\n" + "(pid %d); deleting it here would rip it out under the daemon.\n" "Use the daemon API instead:\n" " curl --unix-socket %s/ublkd.sock -X POST " "-d '{\"dev_id\":%d}' http://d/v1/del\n", dev_id, (int)pid, OVERLAYBD_UBLK_RUN_DIR, dev_id); return 1; } - if (kill(pid, SIGTERM) != 0) { - if (errno == ESRCH) { - fprintf(stderr, "overlaybd-ublk: dev %d daemon (pid %d) not running, " - "removing stale pidfile\n", - dev_id, (int)pid); - char path[128]; - snprintf(path, sizeof(path), "%s/%d.pid", OVERLAYBD_UBLK_RUN_DIR, dev_id); - unlink(path); + + if (alive) { + // live single-device daemon: external STOP, then wait for its exit + // (it drains in-flight IO and releases the device references) + r = ublk_kernel_stop_dev(dev_id); + if (r < 0 && r != -ENODEV) + fprintf(stderr, "overlaybd-ublk: STOP_DEV: %s (continuing)\n", + strerror(-r)); + int waited; + for (waited = 0; waited < 300; waited++) { // up to 30s + if (kill(pid, 0) != 0 && errno == ESRCH) + break; + usleep(100 * 1000); + } + if (waited == 300) { + fprintf(stderr, + "overlaybd-ublk: owner pid %d did not exit in 30s " + "(wedged?). Aborting -- deleting a device with stuck " + "IO can hang the machine on backported kernels. " + "Retry with --force to SIGKILL the owner first.\n", + (int)pid); return 1; } - fprintf(stderr, "overlaybd-ublk: kill pid %d failed: %s\n", (int)pid, - strerror(errno)); - return 1; + } else { + // orphan (or just-killed --force owner): references dropped, kernel + // auto-stopped; STOP may fail depending on state -- best effort + ublk_kernel_stop_dev(dev_id); } - // wait for the daemon to finish teardown (device gone when it exits) - for (int i = 0; i < 300; i++) { // up to 30s - if (kill(pid, 0) != 0 && errno == ESRCH) { - printf("/dev/ublkb%d removed\n", dev_id); - return 0; - } - usleep(100 * 1000); + + r = ublk_kernel_del_dev(dev_id); + if (r < 0 && r != -ENODEV && r != -ENOENT) { + fprintf(stderr, "overlaybd-ublk: DEL_DEV(%d) failed: %s\n", dev_id, + strerror(-r)); + return 1; } - fprintf(stderr, "overlaybd-ublk: dev %d daemon (pid %d) did not exit in 30s\n", dev_id, - (int)pid); - return 1; + remove_pidfile(dev_id); + printf("/dev/ublkb%d removed\n", dev_id); + return 0; } +// list (A2): the KERNEL is the source of truth -- scan /sys/block for +// ublkbN, ask GET_DEV_INFO for the owner, and grade each device: +// running / [ublkd-managed] / ORPHAN (kernel device without a live owner). +// Stale pidfiles without a kernel device are reported for cleanup. static int cmd_list() { - DIR *dir = opendir(OVERLAYBD_UBLK_RUN_DIR); - if (dir == nullptr) - return 0; // nothing ever started on this host - struct dirent *ent; - while ((ent = readdir(dir)) != nullptr) { - int dev_id; - if (sscanf(ent->d_name, "%d.pid", &dev_id) != 1) - continue; - pid_t pid; - if (read_pidfile(dev_id, &pid) != 0) - continue; - bool alive = (kill(pid, 0) == 0); - // best effort: show the image config from the daemon's cmdline - std::string cmdline; - char proc_path[64], buf[4096]; - snprintf(proc_path, sizeof(proc_path), "/proc/%d/cmdline", (int)pid); - int fd = open(proc_path, O_RDONLY); - if (fd >= 0) { - ssize_t n = read(fd, buf, sizeof(buf) - 1); - close(fd); - for (ssize_t i = 0; i < n; i++) - buf[i] = buf[i] ? buf[i] : ' '; - if (n > 0) { - buf[n] = 0; - cmdline = buf; + bool seen[1024] = {}; + DIR *bd = opendir("/sys/block"); + if (bd != nullptr) { + struct dirent *ent; + while ((ent = readdir(bd)) != nullptr) { + int dev_id; + if (sscanf(ent->d_name, "ublkb%d", &dev_id) != 1) + continue; + if (dev_id >= 0 && dev_id < 1024) + seen[dev_id] = true; + UblkKernelDevInfo info; + if (ublk_kernel_get_dev_info(dev_id, info) != 0) + continue; + pid_t pid = info.owner_pid; + bool alive = pid > 0 && kill(pid, 0) == 0; + const char *status = !alive ? "ORPHAN" + : pid_is_ublkd(pid) ? "[ublkd-managed]" + : "running"; + // best effort: show the image config from the owner's cmdline + std::string cmdline; + if (alive) { + char proc_path[64], buf[4096]; + snprintf(proc_path, sizeof(proc_path), "/proc/%d/cmdline", (int)pid); + int fd = open(proc_path, O_RDONLY); + if (fd >= 0) { + ssize_t n = read(fd, buf, sizeof(buf) - 1); + close(fd); + for (ssize_t i = 0; i < n; i++) + buf[i] = buf[i] ? buf[i] : ' '; + if (n > 0) { + buf[n] = 0; + cmdline = buf; + } + } } + printf("/dev/ublkb%-4d pid %-8d %-15s %s\n", dev_id, (int)pid, status, + cmdline.c_str()); + } + closedir(bd); + } + // Residuals: after an owner dies, this kernel auto-STOPs the device + // (gendisk gone, invisible in /sys/block) but does NOT free the id -- + // /dev/ublkcN stays behind. Scan for those, they are reclaimable. + DIR *dd = opendir("/dev"); + if (dd != nullptr) { + struct dirent *ent; + while ((ent = readdir(dd)) != nullptr) { + int dev_id; + if (sscanf(ent->d_name, "ublkc%d", &dev_id) != 1) + continue; + if (dev_id < 0 || dev_id >= 1024 || seen[dev_id]) + continue; + seen[dev_id] = true; + UblkKernelDevInfo info; + if (ublk_kernel_get_dev_info(dev_id, info) != 0) + continue; + printf("ublk dev %-6d pid %-8d %-15s (stopped residual, id still " + "allocated; reclaim with: del -n %d)\n", + dev_id, info.owner_pid, "RESIDUAL", dev_id); + } + closedir(dd); + } + // pidfiles whose kernel device is gone: stale, worth flagging + DIR *rd = opendir(OVERLAYBD_UBLK_RUN_DIR); + if (rd != nullptr) { + struct dirent *ent; + while ((ent = readdir(rd)) != nullptr) { + int dev_id; + if (sscanf(ent->d_name, "%d.pid", &dev_id) != 1) + continue; + if (dev_id >= 0 && dev_id < 1024 && !seen[dev_id]) + printf("(stale pidfile %s/%d.pid: no kernel device)\n", + OVERLAYBD_UBLK_RUN_DIR, dev_id); } - printf("/dev/ublkb%-4d pid %-8d %-8s %s%s\n", dev_id, (int)pid, - alive ? "running" : "dead", - pid_is_ublkd(pid) ? "[ublkd-managed] " : "", cmdline.c_str()); + closedir(rd); } - closedir(dir); return 0; } @@ -220,7 +330,7 @@ int main(int argc, char **argv) { case UblkCliCmd::Kind::ADD: return cmd_add(cmd.opts, cmd.foreground); case UblkCliCmd::Kind::DEL: - return cmd_del(cmd.del_dev_id); + return cmd_del(cmd.del_dev_id, cmd.del_force); case UblkCliCmd::Kind::LIST: return cmd_list(); default: diff --git a/src/ublk/test/ublk_dispatch_test.cpp b/src/ublk/test/ublk_dispatch_test.cpp index f5aba38b..70af63f1 100644 --- a/src/ublk/test/ublk_dispatch_test.cpp +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -224,6 +224,11 @@ TEST(cli, del_requires_dev_id) { ASSERT_EQ(parse({"del", "-n", "5"}, cmd2), 0); EXPECT_EQ(cmd2.kind, UblkCliCmd::Kind::DEL); EXPECT_EQ(cmd2.del_dev_id, 5); + EXPECT_FALSE(cmd2.del_force); // default: refuse risky teardown + + UblkCliCmd cmd3; + ASSERT_EQ(parse({"del", "-n", "5", "--force"}, cmd3), 0); + EXPECT_TRUE(cmd3.del_force); } TEST(cli, list_and_missing_subcommand) { diff --git a/src/ublk/ublk_device.cpp b/src/ublk/ublk_device.cpp index 44fce859..5351a141 100644 --- a/src/ublk/ublk_device.cpp +++ b/src/ublk/ublk_device.cpp @@ -108,6 +108,7 @@ class ImageFileTarget : public UblkIOTarget { // --------------------------------------------------------------------------- void UblkDevice::stop() { + stop_requested_ = true; // lifecycle stays ours: teardown may self-DEL if (ctrl_dev_ != nullptr) ublksrv_ctrl_stop_dev(ctrl_dev_); } @@ -750,6 +751,40 @@ static int ublk_raw_ctrl_cmd_buf(uint32_t cmd_op, int dev_id, void *buf, uint16_ return ublk_raw_ctrl(cmd_op, dev_id, 0, buf, len); } +// ioctl-encoded op first, legacy numeric opcode as fallback: backport +// kernels may predate UBLK_F_CMD_IOCTL_ENCODE and reject _IO*-encoded ops +static int ublk_ctrl_compat(uint32_t ioctl_op, uint32_t legacy_op, int dev_id, + uint64_t data0, void *buf, uint16_t len) { + int r = ublk_raw_ctrl(ioctl_op, dev_id, data0, buf, len); + if (r == -EOPNOTSUPP || r == -EINVAL || r == -ENOTTY) + r = ublk_raw_ctrl(legacy_op, dev_id, data0, buf, len); + return r; +} + +int ublk_kernel_get_dev_info(int dev_id, UblkKernelDevInfo &info) { + struct ublksrv_ctrl_dev_info raw; + memset(&raw, 0, sizeof(raw)); + int r = ublk_ctrl_compat(UBLK_U_CMD_GET_DEV_INFO, UBLK_CMD_GET_DEV_INFO, dev_id, + 0, &raw, sizeof(raw)); + if (r < 0) + return r; + info.dev_id = raw.dev_id; + info.owner_pid = raw.ublksrv_pid; + info.state = raw.state; + info.flags = raw.flags; + return 0; +} + +int ublk_kernel_stop_dev(int dev_id) { + return ublk_ctrl_compat(UBLK_U_CMD_STOP_DEV, UBLK_CMD_STOP_DEV, dev_id, 0, + nullptr, 0); +} + +int ublk_kernel_del_dev(int dev_id) { + return ublk_ctrl_compat(UBLK_U_CMD_DEL_DEV, UBLK_CMD_DEL_DEV, dev_id, 0, + nullptr, 0); +} + // UBLK_F_UPDATE_SIZE support, probed once per process (GET_FEATURES itself // is 6.5+; on kernels without it the probe fails -> no support) static bool kernel_supports_update_size() { @@ -924,8 +959,19 @@ void UblkDevice::teardown() { if (torn_down_) return; torn_down_ = true; - if (ctrl_dev_ != nullptr) - ctrl_del_and_deinit(); + if (ctrl_dev_ != nullptr) { + if (stop_requested_ || !queue_exited_.load()) { + ctrl_del_and_deinit(); + } else { + // The queue died without stop() being called: an external actor + // (the new `del`) stopped the device through /dev/ublk-control + // and owns the DEL -- doing it here too would race (A2). Just + // release our ctrl handle and exit fast. + LOG_INFO("externally stopped, leaving DEL_DEV to the external actor"); + ublksrv_ctrl_deinit(ctrl_dev_); + ctrl_dev_ = nullptr; + } + } delete target_; target_ = nullptr; delete file_; diff --git a/src/ublk/ublk_device.h b/src/ublk/ublk_device.h index 8aa3854b..d3f68f27 100644 --- a/src/ublk/ublk_device.h +++ b/src/ublk/ublk_device.h @@ -68,6 +68,22 @@ int ublk_daemon_setup_cache(const std::string &cache_base, const std::string &service_config_path, std::string &patched_config, int &lock_fd); +// Kernel-view device access for del/list (A2, ADR-0006 "del 外部化"): +// STOP/DEL are per-dev_id control commands that any root process may send +// through /dev/ublk-control -- no owner process needed. Ops therefore work +// on orphans (owner killed -9) and never rely on pidfiles. Commands are +// sent ioctl-encoded first with a legacy-opcode retry (old backport +// kernels may predate UBLK_F_CMD_IOCTL_ENCODE). +struct UblkKernelDevInfo { + int dev_id = -1; + int owner_pid = -1; // the serving process as the KERNEL sees it + uint16_t state = 0; // UBLK_S_DEV_DEAD/LIVE/... + uint64_t flags = 0; +}; +int ublk_kernel_get_dev_info(int dev_id, UblkKernelDevInfo &info); // -ENODEV: gone +int ublk_kernel_stop_dev(int dev_id); +int ublk_kernel_del_dev(int dev_id); + class ImageService; class ImageFile; class ImageFileTarget; @@ -139,6 +155,7 @@ class UblkDevice { const struct ublksrv_dev *dev_ = nullptr; std::thread queue_thread_; std::atomic queue_exited_{false}; + bool stop_requested_ = false; // stop() was called (signal/daemon del) unsigned ring_flags_ = 0; // queue io_uring flags, probed in start() int cache_lock_fd_ = -1; // held for the device's lifetime // human-readable reason of a failed bring-up; relayed to the add From aa56d6fa8bcf7bcc9584013052eb0006881f8e52 Mon Sep 17 00:00:00 2001 From: haolianglh Date: Tue, 28 Jul 2026 23:02:25 +0800 Subject: [PATCH 8/8] Add shared read-only device leases to ublkd Add POST /v1/acquire and /v1/release to the daemon, a lease-style counterpart to the ownership-style add/del. Acquiring the same read-only image in shared mode returns the same device with an incremented refcount instead of creating a new one, so N consumers of a golden image share a single ublk device (and a single ImageFile and cache) rather than N. Release decrements the refcount and tears the device down only when it reaches zero. `acquire {"mode":"exclusive"}` always creates a fresh device; it is equivalent to add today and is the hook for a future warm pool, so the protocol will not change when that lands. Shared mode is refused for writable images (concurrent writers would corrupt the upper) and the half-created device is rolled back. Cross-verb guards: del refuses a shared device and points at release; release on an exclusive device is allowed (equivalent to del). list now reports mode and refcount per device. Protocol parsing and encoding are covered by unit tests (26 total); runtime verified on a ublk-capable kernel: shared reuse and refcounting, release teardown at zero, writable rejection with rollback, exclusive/shared orthogonality, cross-verb guards, and shutdown cleanup of shared devices. Signed-off-by: haolianglh --- src/ublk/test/ublk_dispatch_test.cpp | 36 +++++++ src/ublk/ublkd_main.cpp | 153 +++++++++++++++++++++++++++ src/ublk/ublkd_protocol.cpp | 52 +++++++++ src/ublk/ublkd_protocol.h | 17 +++ 4 files changed, 258 insertions(+) diff --git a/src/ublk/test/ublk_dispatch_test.cpp b/src/ublk/test/ublk_dispatch_test.cpp index 70af63f1..9b2bc5db 100644 --- a/src/ublk/test/ublk_dispatch_test.cpp +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -369,6 +369,38 @@ TEST(ublkd_protocol, parse_resize) { -1); } +TEST(ublkd_protocol, parse_acquire) { + UblkdAcquireRequest req; + std::string err; + // mode defaults to shared + ASSERT_EQ(ublkd_parse_acquire(R"({"config":"/tmp/c.json"})", req, err), 0); + EXPECT_EQ(req.config, "/tmp/c.json"); + EXPECT_EQ(req.mode, "shared"); + + UblkdAcquireRequest ex; + ASSERT_EQ(ublkd_parse_acquire( + R"({"config":"/tmp/c.json","mode":"exclusive"})", ex, err), + 0); + EXPECT_EQ(ex.mode, "exclusive"); + + // malformed: no config / bad mode value / bad mode type / not json + EXPECT_EQ(ublkd_parse_acquire(R"({"mode":"shared"})", req, err), -1); + EXPECT_EQ(ublkd_parse_acquire(R"({"config":"/c","mode":"rw"})", req, err), -1); + EXPECT_EQ(ublkd_parse_acquire(R"({"config":"/c","mode":1})", req, err), -1); + EXPECT_EQ(ublkd_parse_acquire("nope", req, err), -1); +} + +TEST(ublkd_protocol, acquire_release_responses) { + auto acq = ublkd_msg_acquired(3, "/dev/ublkb3", "shared", 2); + EXPECT_NE(acq.find(R"("mode":"shared")"), std::string::npos); + EXPECT_NE(acq.find(R"("refcount":2)"), std::string::npos); + EXPECT_NE(acq.find(R"("dev_id":3)"), std::string::npos); + + EXPECT_NE(ublkd_msg_released(1).find(R"("refcount":1)"), std::string::npos); + // refcount 0 means the device was torn down by this release + EXPECT_NE(ublkd_msg_released(0).find(R"("refcount":0)"), std::string::npos); +} + TEST(ublkd_protocol, responses_roundtrip) { EXPECT_EQ(ublkd_msg_ok(), R"({"ok":true})"); EXPECT_NE(ublkd_msg_error("boom").find(R"("ok":false)"), std::string::npos); @@ -382,8 +414,12 @@ TEST(ublkd_protocol, responses_roundtrip) { devs[0].config = "/tmp/c.json"; devs[0].writable = true; devs[0].state = "running"; + devs[0].mode = "shared"; + devs[0].refcount = 3; auto list = ublkd_msg_list(devs); EXPECT_NE(list.find(R"("writable":true)"), std::string::npos); EXPECT_NE(list.find(R"("state":"running")"), std::string::npos); + EXPECT_NE(list.find(R"("mode":"shared")"), std::string::npos); + EXPECT_NE(list.find(R"("refcount":3)"), std::string::npos); } diff --git a/src/ublk/ublkd_main.cpp b/src/ublk/ublkd_main.cpp index 452ac676..538d5a49 100644 --- a/src/ublk/ublkd_main.cpp +++ b/src/ublk/ublkd_main.cpp @@ -77,6 +77,12 @@ class UblkdServer : public photon::net::http::HTTPHandler { handle_add(body, code, msg); } else if (target == "/v1/del" && req.verb() == photon::net::http::Verb::POST) { handle_del(body, code, msg); + } else if (target == "/v1/acquire" && + req.verb() == photon::net::http::Verb::POST) { + handle_acquire(body, code, msg); + } else if (target == "/v1/release" && + req.verb() == photon::net::http::Verb::POST) { + handle_release(body, code, msg); } else if (target == "/v1/resize" && req.verb() == photon::net::http::Verb::POST) { handle_resize(body, code, msg); } else if (target == "/v1/list") { @@ -177,6 +183,17 @@ class UblkdServer : public photon::net::http::HTTPHandler { msg = ublkd_msg_error("no such device: " + std::to_string(id)); return; } + // del is for exclusive (add-created) devices; shared devices are + // reference-counted and must go through release, or a caller would + // rip a device out from under other holders (M3-A) + if (it->second.mode_shared) { + code = 409; + msg = ublkd_msg_error("device " + std::to_string(id) + + " is shared (refcount " + + std::to_string(it->second.refcount) + + "); use /v1/release"); + return; + } // Deletion state machine (M2-2): del is synchronous and yields while // draining, so a concurrent del of the same id would otherwise race // on the map iterator (erase invalidates the other handler's it). @@ -235,20 +252,156 @@ class UblkdServer : public photon::net::http::HTTPHandler { info.config = kv.second.config; info.writable = kv.second.dev->writable(); info.state = kv.second.stopping ? "stopping" : "running"; + info.mode = kv.second.mode_shared ? "shared" : "exclusive"; + info.refcount = kv.second.mode_shared ? kv.second.refcount : 0; infos.push_back(std::move(info)); } code = 200; msg = ublkd_msg_list(infos); } + // acquire (M3-A): lease semantics. shared -> reuse one device per image + // (refcounted); exclusive -> a fresh device (equivalent to add today, + // a hook for the M3-B warm pool). Only read-only images may be shared. + void handle_acquire(const std::string &body, int &code, std::string &msg) { + UblkdAcquireRequest areq; + std::string err; + if (ublkd_parse_acquire(body, areq, err) != 0) { + code = 400; + msg = ublkd_msg_error(err); + return; + } + char resolved[PATH_MAX]; + if (realpath(areq.config.c_str(), resolved) == nullptr) { + code = 400; + msg = ublkd_msg_error("image config " + areq.config + ": " + + strerror(errno)); + return; + } + bool shared = (areq.mode == "shared"); + + // shared: an already-running device for this image gets one more ref + if (shared) { + auto sit = shared_.find(resolved); + if (sit != shared_.end()) { + auto dit = devices_.find(sit->second); + if (dit != devices_.end() && !dit->second.stopping) { + dit->second.refcount++; + code = 200; + msg = ublkd_msg_acquired(sit->second, + "/dev/ublkb" + std::to_string(sit->second), + "shared", dit->second.refcount); + LOG_INFO("ublkd: acquire(shared) hit dev ` refcount `", + sit->second, dit->second.refcount); + return; + } + if (dit != devices_.end() && dit->second.stopping) { + code = 409; + msg = ublkd_msg_error("shared device for this image is being " + "torn down; retry"); + return; + } + shared_.erase(sit); // stale mapping, fall through to create + } + } + + if (adds_in_flight_.count(resolved)) { + code = 409; + msg = ublkd_msg_error("an acquire/add of this image is already in progress"); + return; + } + adds_in_flight_.insert(resolved); + auto dev = std::make_unique(service_); + UblkDeviceOpts opts; + opts.image_config_path = areq.config; + opts.cache_dir = cache_base_; + int ret = dev->start(opts); + adds_in_flight_.erase(resolved); + if (ret != 0) { + code = 500; + msg = ublkd_msg_error("device failed to start (see daemon log)"); + return; + } + // shared mode requires a read-only image (concurrent RW would corrupt + // the upper); reject after start so writable() is known, then tear down + if (shared && dev->writable()) { + dev->stop(); + dev->wait(); + dev->teardown(); + code = 400; + msg = ublkd_msg_error("cannot share a writable image (only read-only " + "images may be acquired shared)"); + return; + } + int id = dev->dev_id(); + auto &entry = devices_[id]; + entry.dev = std::move(dev); + entry.config = areq.config; + entry.mode_shared = shared; + entry.refcount = 1; + if (shared) + shared_[resolved] = id; + code = 200; + msg = ublkd_msg_acquired(id, "/dev/ublkb" + std::to_string(id), + shared ? "shared" : "exclusive", 1); + LOG_INFO("ublkd: acquired /dev/ublkb` mode ` image `", id, + areq.mode.c_str(), areq.config.c_str()); + } + + void handle_release(const std::string &body, int &code, std::string &msg) { + int id = -1; + std::string err; + if (ublkd_parse_del(body, id, err) != 0) { // same body shape as del + code = 400; + msg = ublkd_msg_error(err); + return; + } + auto it = devices_.find(id); + if (it == devices_.end()) { + code = 404; + msg = ublkd_msg_error("no such device: " + std::to_string(id)); + return; + } + if (it->second.stopping) { + code = 409; + msg = ublkd_msg_error("device " + std::to_string(id) + + " is being torn down"); + return; + } + if (--it->second.refcount > 0) { + code = 200; + msg = ublkd_msg_released(it->second.refcount); + LOG_INFO("ublkd: release dev ` refcount `", id, it->second.refcount); + return; + } + // last reference (or an exclusive lease): tear the device down + if (it->second.mode_shared) { + char resolved[PATH_MAX]; + if (realpath(it->second.config.c_str(), resolved) != nullptr) + shared_.erase(resolved); + } + it->second.stopping = true; + it->second.dev->stop(); + it->second.dev->wait(); + it->second.dev->teardown(); + devices_.erase(id); + code = 200; + msg = ublkd_msg_released(0); + LOG_INFO("ublkd: released and removed /dev/ublkb`", id); + } + ImageService *service_; std::string cache_base_; struct DevEntry { std::unique_ptr dev; std::string config; bool stopping = false; + bool mode_shared = false; // acquired shared (refcounted) vs add/exclusive + int refcount = 1; // meaningful when mode_shared }; std::map devices_; + // realpath(image config) -> dev_id of its shared device (M3-A) + std::map shared_; // realpath'd image configs with an add in progress (UX guard, see above) std::set adds_in_flight_; }; diff --git a/src/ublk/ublkd_protocol.cpp b/src/ublk/ublkd_protocol.cpp index fdb0fe53..e80f7681 100644 --- a/src/ublk/ublkd_protocol.cpp +++ b/src/ublk/ublkd_protocol.cpp @@ -102,6 +102,34 @@ int ublkd_parse_resize(const std::string &body, UblkdResizeRequest &req, return 0; } +int ublkd_parse_acquire(const std::string &body, UblkdAcquireRequest &req, + std::string &err) { + rapidjson::Document doc; + doc.Parse(body.c_str()); + if (doc.HasParseError() || !doc.IsObject()) { + err = "request body is not a JSON object"; + return -1; + } + if (!doc.HasMember("config") || !doc["config"].IsString() || + doc["config"].GetStringLength() == 0) { + err = "missing required field: config"; + return -1; + } + req.config = doc["config"].GetString(); + if (doc.HasMember("mode")) { + if (!doc["mode"].IsString()) { + err = "mode must be a string"; + return -1; + } + req.mode = doc["mode"].GetString(); + if (req.mode != "shared" && req.mode != "exclusive") { + err = "mode must be 'shared' or 'exclusive'"; + return -1; + } + } + return 0; +} + static std::string dump(const rapidjson::Document &doc) { rapidjson::StringBuffer sb; rapidjson::Writer writer(sb); @@ -132,6 +160,28 @@ std::string ublkd_msg_added(int dev_id, const std::string &path) { return dump(doc); } +std::string ublkd_msg_acquired(int dev_id, const std::string &path, + const std::string &mode, int refcount) { + rapidjson::Document doc; + doc.SetObject(); + auto &a = doc.GetAllocator(); + doc.AddMember("ok", true, a); + doc.AddMember("dev_id", dev_id, a); + doc.AddMember("dev", rapidjson::Value(path.c_str(), a), a); + doc.AddMember("mode", rapidjson::Value(mode.c_str(), a), a); + doc.AddMember("refcount", refcount, a); + return dump(doc); +} + +std::string ublkd_msg_released(int refcount) { + rapidjson::Document doc; + doc.SetObject(); + auto &a = doc.GetAllocator(); + doc.AddMember("ok", true, a); + doc.AddMember("refcount", refcount, a); + return dump(doc); +} + std::string ublkd_msg_list(const std::vector &devices) { rapidjson::Document doc; doc.SetObject(); @@ -145,6 +195,8 @@ std::string ublkd_msg_list(const std::vector &devices) { o.AddMember("config", rapidjson::Value(d.config.c_str(), a), a); o.AddMember("writable", d.writable, a); o.AddMember("state", rapidjson::Value(d.state.c_str(), a), a); + o.AddMember("mode", rapidjson::Value(d.mode.c_str(), a), a); + o.AddMember("refcount", d.refcount, a); arr.PushBack(o, a); } doc.AddMember("devices", arr, a); diff --git a/src/ublk/ublkd_protocol.h b/src/ublk/ublkd_protocol.h index d24e6002..a3592fb7 100644 --- a/src/ublk/ublkd_protocol.h +++ b/src/ublk/ublkd_protocol.h @@ -37,6 +37,8 @@ struct UblkdDeviceInfo { std::string config; bool writable = false; std::string state; // "running" / "stopping" + std::string mode; // "exclusive" / "shared" + int refcount = 0; // shared devices only (0 for exclusive) }; // parse POST /v1/add body; 0 = ok, -1 = malformed (err says why) @@ -45,6 +47,16 @@ int ublkd_parse_add(const std::string &body, UblkdAddRequest &req, std::string & // parse POST /v1/del body ({"dev_id":N}); 0 = ok, -1 = malformed int ublkd_parse_del(const std::string &body, int &dev_id, std::string &err); +struct UblkdAcquireRequest { + std::string config; // image config path, required + std::string mode = "shared"; // "shared" (default) | "exclusive" +}; + +// parse POST /v1/acquire body; 0 = ok, -1 = malformed. mode defaults to +// "shared"; any value other than shared/exclusive is rejected. +int ublkd_parse_acquire(const std::string &body, UblkdAcquireRequest &req, + std::string &err); + struct UblkdResizeRequest { int dev_id = -1; uint64_t size_gb = 0; // new virtual size, must grow @@ -59,5 +71,10 @@ int ublkd_parse_resize(const std::string &body, UblkdResizeRequest &req, std::string ublkd_msg_ok(); // {"ok":true} std::string ublkd_msg_error(const std::string &error); // {"ok":false,...} std::string ublkd_msg_added(int dev_id, const std::string &path); // add success +// acquire success: like added, plus mode and refcount +std::string ublkd_msg_acquired(int dev_id, const std::string &path, + const std::string &mode, int refcount); +// release success: {"ok":true,"refcount":n} (0 = device was torn down) +std::string ublkd_msg_released(int refcount); std::string ublkd_msg_list(const std::vector &devices); std::string ublkd_msg_ping(const std::string &version);