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..d688ecab 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,73 @@ 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). + +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 + the daemon): the daemon serves the device's IO, so tearing it down with a + mounted filesystem aborts in-flight journal writes. +* 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` + 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/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 new file mode 100644 index 00000000..f6c65f99 --- /dev/null +++ b/src/ublk/CMakeLists.txt @@ -0,0 +1,55 @@ +# 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 + config_patch.cpp + ublkd_protocol.cpp + pool_placeholder.cpp + blkdev_hygiene.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) + +# overlaybd-ublkd: daemon mode, all devices in one process +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) +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/../example_config/overlaybd-ublkd.service + DESTINATION /opt/overlaybd/) + +if (BUILD_TESTING) + add_subdirectory(test) +endif () diff --git a/src/ublk/blkdev_hygiene.cpp b/src/ublk/blkdev_hygiene.cpp new file mode 100644 index 00000000..c3ee6597 --- /dev/null +++ b/src/ublk/blkdev_hygiene.cpp @@ -0,0 +1,79 @@ +/* + 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 "blkdev_hygiene.h" + +#include + +#include +#include + +#include +#include // BLKFLSBUF +#include +#include +#include // major/minor +#include + +static std::string dev_path(int dev_id) { + return "/dev/ublkb" + std::to_string(dev_id); +} + +bool ublk_device_is_mounted(int dev_id) { + struct stat st; + std::string path = dev_path(dev_id); + if (stat(path.c_str(), &st) != 0 || !S_ISBLK(st.st_mode)) { + LOG_WARN("cannot stat ` for mount check, assuming mounted", path.c_str()); + return true; // fail safe + } + char want[32]; + snprintf(want, sizeof(want), "%u:%u", major(st.st_rdev), minor(st.st_rdev)); + + FILE *fp = fopen("/proc/self/mountinfo", "r"); + if (fp == nullptr) { + LOG_WARN("cannot read mountinfo, assuming ` is mounted", path.c_str()); + return true; // fail safe + } + // mountinfo: id parent major:minor root mountpoint ... + char line[4096]; + bool mounted = false; + while (fgets(line, sizeof(line), fp) != nullptr) { + int id, parent; + char majmin[64]; + if (sscanf(line, "%d %d %63s", &id, &parent, majmin) != 3) + continue; + if (strcmp(majmin, want) == 0) { + mounted = true; + break; + } + } + fclose(fp); + return mounted; +} + +int ublk_device_flush_buffers(int dev_id) { + std::string path = dev_path(dev_id); + int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -errno; + int ret = 0; + // BLKFLSBUF writes back dirty pages and then invalidates the cache + if (ioctl(fd, BLKFLSBUF, 0) != 0) + ret = -errno; + close(fd); + if (ret != 0) + LOG_WARN("BLKFLSBUF on ` failed: `", path.c_str(), strerror(-ret)); + return ret; +} diff --git a/src/ublk/blkdev_hygiene.h b/src/ublk/blkdev_hygiene.h new file mode 100644 index 00000000..d22ec0bf --- /dev/null +++ b/src/ublk/blkdev_hygiene.h @@ -0,0 +1,38 @@ +/* + 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 + +// Block-device hygiene for recycling pooled devices. +// +// A pooled device stays LIVE across tenants (that is the whole point: STOP and +// START are the ~8ms we are avoiding), so the kernel never drops its page +// cache on its own. Without the calls below a new tenant could read pages +// cached by the previous one. A server whose consumers only ever use O_DIRECT +// would not need this; ours may mount filesystems, so we must handle it. + +// Whether /dev/ublkb currently backs a mount (scans +// /proc/self/mountinfo for the device's major:minor). A still-mounted device +// must never be recycled: BLKFLSBUF cannot invalidate pages a live filesystem +// holds. Returns true also on inspection failure -- fail safe. +bool ublk_device_is_mounted(int dev_id); + +// Flush and invalidate the block device's page cache +// (ioctl BLKFLSBUF, same as `blockdev --flushbufs`). Returns 0 on success, +// -errno otherwise. +int ublk_device_flush_buffers(int dev_id); diff --git a/src/ublk/cli.cpp b/src/ublk/cli.cpp new file mode 100644 index 00000000..a1b8066c --- /dev/null +++ b/src/ublk/cli.cpp @@ -0,0 +1,73 @@ +/* + 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_option("--cache-dir", cmd.opts.cache_dir, + "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", "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"); + + 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..fef4cd09 --- /dev/null +++ b/src/ublk/cli.h @@ -0,0 +1,35 @@ +/* + 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 + 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 +// 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/config_patch.cpp b/src/ublk/config_patch.cpp new file mode 100644 index 00000000..91e12ae0 --- /dev/null +++ b/src/ublk/config_patch.cpp @@ -0,0 +1,88 @@ +/* + 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 +#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; + 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..0ef09337 --- /dev/null +++ b/src/ublk/config_patch.h @@ -0,0 +1,40 @@ +/* + 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); + +// 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/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..8e0c7978 --- /dev/null +++ b/src/ublk/main.cpp @@ -0,0 +1,339 @@ +/* + 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 + +// 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: 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 (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); 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 (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; + } + } 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); + } + + 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; + } + remove_pidfile(dev_id); + printf("/dev/ublkb%d removed\n", dev_id); + return 0; +} + +// list: 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() { + 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); + } + closedir(rd); + } + 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[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) { + off += n; + if (memchr(buf, '\n', off) != nullptr) + 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; +} + +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, cmd.del_force); + case UblkCliCmd::Kind::LIST: + return cmd_list(); + default: + return 1; + } +} diff --git a/src/ublk/pool_placeholder.cpp b/src/ublk/pool_placeholder.cpp new file mode 100644 index 00000000..151e0a65 --- /dev/null +++ b/src/ublk/pool_placeholder.cpp @@ -0,0 +1,106 @@ +/* + 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 "pool_placeholder.h" + +#include "../overlaybd/lsmt/file.h" + +#include +#include + +#include +#include +#include + +#include + +// mirrors overlaybd-create's sparse path: open data/index, create a sparse RW +// LSMT layer of the requested virtual size, close it again +static int create_sparse_layer(const std::string &data_path, + const std::string &index_path, uint64_t vsize) { + const int flag = O_RDWR | O_EXCL | O_CREAT; + const mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH; + photon::fs::IFile *fdata = photon::fs::open_localfile_adaptor(data_path.c_str(), flag, mode); + if (fdata == nullptr) { + LOG_ERROR("cannot create placeholder data `: `", data_path.c_str(), strerror(errno)); + return -1; + } + photon::fs::IFile *findex = + photon::fs::open_localfile_adaptor(index_path.c_str(), flag, mode); + if (findex == nullptr) { + LOG_ERROR("cannot create placeholder index `: `", index_path.c_str(), strerror(errno)); + delete fdata; + return -1; + } + LSMT::LayerInfo args(fdata, findex); + args.virtual_size = vsize; + args.sparse_rw = true; + LSMT::IFileRW *file = LSMT::create_file_rw(args, false); + if (file == nullptr) { + LOG_ERROR("create_file_rw failed for placeholder `", data_path.c_str()); + delete fdata; + delete findex; + return -1; + } + delete file; // closes the layer; fdata/findex are not owned by it + delete fdata; + delete findex; + return 0; +} + +static bool file_exists(const std::string &path) { + struct stat st; + return stat(path.c_str(), &st) == 0; +} + +int ublk_pool_placeholder(const std::string &dir, uint64_t size_gb, int slot, + std::string &out_config) { + std::string base = dir + "/" + std::to_string(size_gb) + "-" + std::to_string(slot); + std::string data_path = base + ".data"; + std::string index_path = base + ".index"; + std::string config_path = base + ".json"; + + // note: a sparse layer's index file is legitimately 0 bytes, so existence + // (not size) is the reuse criterion + if (file_exists(data_path) && file_exists(index_path) && file_exists(config_path)) { + out_config = config_path; // reuse across daemon restarts + return 0; + } + // partial leftovers would fail O_EXCL; start clean + unlink(data_path.c_str()); + unlink(index_path.c_str()); + unlink(config_path.c_str()); + + if (create_sparse_layer(data_path, index_path, size_gb << 30) != 0) + return -1; + + // minimal writable image config: no lowers, a sparse upper + std::ofstream out(config_path, std::ios::trunc); + if (!out) { + LOG_ERROR("cannot write placeholder config `", config_path.c_str()); + return -1; + } + out << "{\"lowers\":[],\"upper\":{\"index\":\"" << index_path << "\",\"data\":\"" + << data_path << "\"}}\n"; + if (!out) { + LOG_ERROR("failed writing placeholder config `", config_path.c_str()); + return -1; + } + out.close(); + LOG_INFO("built pool placeholder image ` (` GB slot `)", config_path.c_str(), + size_gb, slot); + out_config = config_path; + return 0; +} diff --git a/src/ublk/pool_placeholder.h b/src/ublk/pool_placeholder.h new file mode 100644 index 00000000..45d1dac7 --- /dev/null +++ b/src/ublk/pool_placeholder.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 +#include + +// Warm-pool placeholder images. A pooled ublk device must be backed by *some* +// image at creation time; the daemon therefore builds its own throwaway sparse +// LSMT upper and points the pooled device at it until a real image is +// hot-swapped in. Built in-process (a sparse RW layer plus a minimal image +// config), without shelling out to overlaybd-create. +// +// Creates /-.data, .index and .json if absent; the config +// path is returned in out_config. Each pooled device needs its OWN placeholder +// (hence `slot`): two ImageFiles opening the same LSMT sparse layer read-write +// would corrupt each other's layer state. Sparse files cost no real disk. +// Idempotent: an existing triple is reused as is. Returns 0 on success, -1 +// with a logged reason otherwise. +int ublk_pool_placeholder(const std::string &dir, uint64_t size_gb, int slot, + std::string &out_config); diff --git a/src/ublk/test/CMakeLists.txt b/src/ublk/test/CMakeLists.txt new file mode 100644 index 00000000..10e5935e --- /dev/null +++ b/src/ublk/test/CMakeLists.txt @@ -0,0 +1,24 @@ +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 + ../config_patch.cpp + ../ublkd_protocol.cpp +) +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( + 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..651b1672 --- /dev/null +++ b/src/ublk/test/ublk_dispatch_test.cpp @@ -0,0 +1,425 @@ +/* + 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 "../config_patch.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, 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); + 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); + 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) { + 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); +} + +// --------------------------------------------------------------------------- +// 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 +} + +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")); +} + +// --------------------------------------------------------------------------- +// ublkd control protocol codec: 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, 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, 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); + 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"; + 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/ublk_device.cpp b/src/ublk/ublk_device.cpp new file mode 100644 index 00000000..f2ad57e7 --- /dev/null +++ b/src/ublk/ublk_device.cpp @@ -0,0 +1,1151 @@ +/* + 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 "config_patch.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 + +#include +#include +#include +#include +#include +#include + +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; +}; + +// --------------------------------------------------------------------------- +// UblkDevice small members (the class declaration lives in ublk_device.h; +// one instance == one device, N instances per process is the daemon mode) +// --------------------------------------------------------------------------- + +ImageFileTarget *ublk_make_image_target(ImageFile *file) { + return new ImageFileTarget(file); +} + +void UblkDevice::stop() { + stop_requested_ = true; // lifecycle stays ours: teardown may self-DEL + 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; +} + +bool UblkDevice::writable() const { + return file_ != nullptr && !file_->read_only; +} + +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_); + if (swap_efd_ >= 0) + close(swap_efd_); +} + +// --------------------------------------------------------------------------- +// data plane: per-queue photon-owned hybrid event loop +// --------------------------------------------------------------------------- + +struct QueueCtx { + const struct ublksrv_queue *q = nullptr; + photon::ThreadPool<32> *pool = nullptr; + UblkIOTarget *target = 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(ctx->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 *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; +} + +static struct ublksrv_tgt_type obd_tgt_type = { + .handle_io_async = obd_handle_io_async, + .init_tgt = obd_init_tgt, + .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 +// 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. +// Hot-swap handshake: the control coroutine fills a request and waits; +// the queue thread applies it at inflight == 0. Only the queue thread writes +// ctx.target, and it is the only reader too, so the exchange needs no atomics +// beyond publishing the request pointer itself. +struct UblkDevice::SwapRequest { + UblkIOTarget *new_target = nullptr; // ctx.target is the base type + UblkIOTarget *old_target = nullptr; + photon::semaphore done; + bool applied = false; +}; + +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()); + + photon::ThreadPool<32> pool; + QueueCtx ctx; + ctx.pool = &pool; + ctx.target = target_; + + const struct ublksrv_queue *q = + 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); + 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); + + // waker coroutine: turns a cross-thread eventfd write into an in-vcpu + // interrupt of this loop, so a posted swap is picked up immediately + // instead of after the next ring-fd timeout (see swap_efd_ in the header) + photon::thread *loop_th = photon::CURRENT; + bool waker_stop = false; + photon::semaphore waker_done; + photon::thread_create11([&] { + while (!waker_stop) { + if (photon::wait_for_fd_readable(swap_efd_, 200UL * 1000) == 0) { + uint64_t v = 0; + ssize_t n = read(swap_efd_, &v, sizeof(v)); + (void)n; + if (!waker_stop) + photon::thread_interrupt(loop_th, EINTR); // same vcpu + } + } + waker_done.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 && errno != EINTR) { + // EINTR is the waker telling us a swap is pending + 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); + + // hot-swap point: applied by this thread only, and only while + // no IO is in flight -- the dispatch target has no other reader + SwapRequest *swap = swap_pending_.load(std::memory_order_acquire); + if (swap != nullptr && ctx.inflight == 0) { + swap->old_target = ctx.target; + ctx.target = swap->new_target; + swap->applied = true; + swap_pending_.store(nullptr, std::memory_order_release); + swap->done.signal(1); + LOG_INFO("ublk dev ` image hot-swapped", dev_id_); + } + + 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) + ; + + // stop the waker before this thread's photon env goes away + waker_stop = true; + uint64_t one = 1; + ssize_t nw = write(swap_efd_, &one, sizeof(one)); + (void)nw; + waker_done.wait(1, 2UL * 1000 * 1000); + + LOG_INFO("ublk queue exited"); + ublksrv_queue_deinit(q); + queue_exited_.store(true); +} + +// --------------------------------------------------------------------------- +// control plane: ctrl dev lifecycle, owned by the main thread +// --------------------------------------------------------------------------- + +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)); + 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 = image_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(ctrl_dev_, &p); + if (ret) + LOG_ERROR("ublk dev ` set params failed: `", info->dev_id, ret); +} + +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); +} + +// 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 +// 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). +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 = 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 + ctrl_dev_ = nullptr; + return; + } + del_thread.join(); + delete done; + } + ublksrv_ctrl_deinit(ctrl_dev_); + 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 std::string &service_config_path, + const std::string &cache_root, + std::string &out_path) { + const char *base_path = service_config_path.empty() + ? "/etc/overlaybd/overlaybd.json" // create_image_service default + : 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, cache_root, 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; +} + +// 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; +} + +// 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 = 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; + } + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + close(fd); + return -2; + } + 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; +} + +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) { + last_error_ = "image config " + opts.image_config_path + ": " + strerror(errno); + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); + 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) { + 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)) { + 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) { + 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) { + 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; + } + + 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) { + last_error_ = "cannot create cache directories under " + image_root; + return -1; + } + } + last_error_ = "all 64 cache instance slots of this image are busy"; + fprintf(stderr, "overlaybd-ublk: %s\n", last_error_.c_str()); + return -1; +} + +// CLI path only: mandatory cache isolation + own ImageService +int UblkDevice::init_service(const UblkDeviceOpts &opts) { + 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 (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()); + 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 + if (!opts.log_path.empty()) { + // APPCFG defaults (10MB x 3) also cover old-style configs without logConfig + uint64_t log_size = 1024UL * 1024 * imgservice_->global_conf.logConfig().logSizeMB(); + int ret = log_output_file(opts.log_path.c_str(), log_size, + 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()); + } + return 0; +} + +// start() failed mid-way: remove whatever was created so that neither the +// kernel nor this object keeps a half-created device (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 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 per-image 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; +} + +// --------------------------------------------------------------------------- +// raw ublk control commands +// --------------------------------------------------------------------------- +// 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); +} + +// 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() { + 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; +} + +uint64_t UblkDevice::dev_sectors() const { + return image_size() >> 9; +} + +uint32_t UblkDevice::block_size() const { + return file_ != nullptr ? file_->block_size : 0; +} + +// See the header for the concurrency argument. The wait is bounded because a +// wedged queue thread must not hang a control request; on timeout the request +// is retracted and the caller keeps its new objects (and should drop the +// device instead of reusing it). +int UblkDevice::swap_image(ImageFile *new_file, ImageFileTarget *new_target, + ImageFile **old_file, ImageFileTarget **old_target) { + if (queue_exited_.load() || !queue_thread_.joinable()) { + LOG_ERROR("cannot swap image on a stopped device"); + return -1; + } + SwapRequest req; + req.new_target = new_target; + SwapRequest *expected = nullptr; + if (!swap_pending_.compare_exchange_strong(expected, &req)) { + LOG_ERROR("another image swap is already pending"); + return -1; + } + // wake the queue loop now instead of waiting out its ring-fd timeout + if (swap_efd_ >= 0) { + uint64_t one = 1; + ssize_t n = write(swap_efd_, &one, sizeof(one)); + (void)n; + } + // the queue loop wakes at least once per second even when idle + if (req.done.wait(1, 3UL * 1000 * 1000) != 0 || !req.applied) { + // retract: only safe while the queue thread has not taken it + SwapRequest *mine = &req; + if (swap_pending_.compare_exchange_strong(mine, nullptr)) { + LOG_ERROR("image swap timed out, request retracted"); + return -1; + } + // raced with the queue thread applying it: wait a little more + if (req.done.wait(1, 1UL * 1000 * 1000) != 0 || !req.applied) { + LOG_ERROR("image swap timed out after retraction race"); + return -1; + } + } + *old_target = static_cast(req.old_target); + *old_file = file_; + file_ = new_file; + target_ = new_target; + return 0; +} + +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 -- + // 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) { + 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; + } + target_ = new ImageFileTarget(file_); + + // probe once, main-thread serialized, before any queue thread exists + ring_flags_ = probe_queue_ring_flags(); + // swap wakeup channel; harmless if the device is never hot-swapped + if (swap_efd_ < 0) { + swap_efd_ = eventfd(0, EFD_CLOEXEC); + if (swap_efd_ < 0) + LOG_WARN("eventfd for hot-swap wakeup unavailable: `", strerror(errno)); + } + + 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; + // 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) { + LOG_ERROR("ublksrv_ctrl_init failed"); + cleanup_failed_start(false); + 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(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; + } + 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); + + dev_ = ublksrv_dev_init(ctrl_dev_); + if (dev_ == nullptr) { + LOG_ERROR("ublksrv_dev_init failed"); + cleanup_failed_start(true); + return -1; + } + + 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; + last_error_ = "queue ring init failed (see overlaybd log / syslog)"; + 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_); + 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()); + 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) { + 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. 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_; + file_ = nullptr; + 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) { + if (ublk_check_control_dev() != 0) + return 1; + mkdir(OVERLAYBD_UBLK_RUN_DIR, 0755); // pidfile dir for libublksrv + + 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; + } + notify_ready(ready_fd, dev_id_); + wait(); + teardown(); + LOG_INFO("overlaybd-ublk exited"); + return 0; +} + +// --------------------------------------------------------------------------- +// 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 -- the daemon binary +// 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 new file mode 100644 index 00000000..2c746226 --- /dev/null +++ b/src/ublk/ublk_device.h @@ -0,0 +1,204 @@ +/* + 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 + +// 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; + // 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; +}; + +// 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); + +// Daemon-side cache setup. 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); + +// Kernel-view device access for del/list: +// 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; + +// Build the dispatch target wrapping an ImageFile: the adapter type +// lives in ublk_device.cpp, so hot-swap callers need this factory. Caller +// owns the result and hands it to UblkDevice::swap_image(). +ImageFileTarget *ublk_make_image_target(ImageFile *file); +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: +// - CLI (default ctor + run()): owns its ImageService, with the mandatory +// per-image cache isolation (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(); + + // Online grow: 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); + + // Hot-swap the backing image (warm pool). The queue thread is the + // only reader of the dispatch target and runs on a single photon vcpu, + // so the swap is performed by the queue thread itself at a moment when + // inflight == 0 -- no atomics, no reader/writer race. Callers (the + // daemon's control coroutine) block until the queue thread acknowledges, + // with a bounded wait. The device keeps its ublk identity (dev_id, + // block size, RO/RW attrs, capacity), so only images matching the + // pool key may be swapped in. Takes ownership of both objects and + // returns the previous ImageFile/target through the out params for the + // caller to release. Returns 0, or -1 on timeout/teardown. + int swap_image(ImageFile *new_file, ImageFileTarget *new_target, + ImageFile **old_file, ImageFileTarget **old_target); + + // Pool key components: what the kernel fixed at creation time and thus + // constrains which images may be swapped in. + uint64_t dev_sectors() const; + uint32_t block_size() const; + + 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 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(); + 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; + // hot-swap handshake with the queue thread; owned by the caller + // of swap_image, published to the queue thread through swap_pending_ + struct SwapRequest; + std::atomic swap_pending_{nullptr}; + // Wakes the queue loop when a swap is posted: the loop otherwise sleeps up + // to a second on the ring fd, which would make a hot swap slower than + // creating a device from scratch. Cross-thread signalling goes through the + // eventfd only; the interrupt itself happens inside the queue thread's own + // vcpu (this photon build has no cross-vcpu safe_thread_interrupt). + int swap_efd_ = -1; + struct ublksrv_ctrl_dev *ctrl_dev_ = nullptr; + 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 + // 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 new file mode 100644 index 00000000..1b6cef0f --- /dev/null +++ b/src/ublk/ublkd_main.cpp @@ -0,0 +1,738 @@ +/* + 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. +// 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), requests are handled globally serialized. + +#include "ublk_device.h" +#include "ublkd_protocol.h" +#include "pool_placeholder.h" +#include "blkdev_hygiene.h" + +#include "../image_service.h" +#include "../image_file.h" +#include "../version.h" + +#include +#include +#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: + 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 + bool pooled = false; // came from the warm pool, can be recycled + int pool_slot = -1; // which placeholder image to swap back in + }; + + UblkdServer(ImageService *service, std::string cache_base) + : service_(service), cache_base_(std::move(cache_base)) { + } + + // warm pool: off unless low > 0. Pooled devices are pre-created on + // daemon-owned placeholder images and handed out by hot-swapping the real + // image in, saving the ~8ms device-shell creation. Only writable images + // are pooled for now: the attrs set at creation time (block size, RO/RW) + // cannot change afterwards. + void configure_pool(int low, int high, uint64_t size_gb) { + pool_low_ = low; + pool_high_ = high > low ? high : low; + pool_size_gb_ = size_gb; + } + + void prewarm_pool() { + refill_pool(); + } + + ~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.dev->stop(); + for (auto &p : pool_idle_) + p.dev->stop(); + for (auto &kv : devices_) { + kv.second.dev->wait(); + kv.second.dev->teardown(); + } + for (auto &p : pool_idle_) { + p.dev->wait(); + p.dev->teardown(); + delete p.dev; + } + pool_idle_.clear(); + 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/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") { + handle_list(code, msg); + } else if (target == "/v1/pool") { + code = 200; + msg = ublkd_msg_pool(pool_low_, pool_high_, (int)pool_idle_.size(), + pool_size_gb_, pool_hits_, pool_misses_); + } 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; + } + // Per-image in-flight guard. Note: 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; + // cache tree is shared daemon-wide; cache_dir here only tells the + // device where the cross-process RW image locks live + 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(); + 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()); + } + + 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; + } + // 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 + 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: 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); + } + + 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_) { + UblkdDeviceInfo info; + info.dev_id = kv.first; + info.dev_path = "/dev/ublkb" + std::to_string(kv.first); + 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: lease semantics. shared -> reuse one device per image + // (refcounted); exclusive -> a fresh device (equivalent to add unless the + // warm pool is enabled). 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); + + // warm pool fast path: exclusive leases may come from the pool + std::unique_ptr dev; + bool from_pool = false; + int pool_slot = -1; + if (!shared && pool_low_ > 0 && !pool_idle_.empty()) { + ImageFile *real = service_->create_image_file(areq.config.c_str(), + next_dev_tag()); + if (real == nullptr) { + adds_in_flight_.erase(resolved); + code = 500; + msg = ublkd_msg_error("cannot open image (see daemon log)"); + return; + } + UblkDevice *pooled = take_pooled(real, &pool_slot); + if (pooled != nullptr) { + dev.reset(pooled); + from_pool = true; + pool_hits_++; + } else { + delete real; // no pooled device matched; fall back to a new one + pool_misses_++; + } + } else if (!shared && pool_low_ > 0) { + pool_misses_++; + } + + if (!from_pool) { + dev = std::make_unique(service_); + UblkDeviceOpts opts; + opts.image_config_path = areq.config; + opts.cache_dir = cache_base_; + if (dev->start(opts) != 0) { + adds_in_flight_.erase(resolved); + code = 500; + msg = ublkd_msg_error("device failed to start (see daemon log)"); + return; + } + } + adds_in_flight_.erase(resolved); + // 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; + entry.pooled = from_pool; + entry.pool_slot = pool_slot; + 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 ` pooled ` image `", id, + areq.mode.c_str(), from_pool ? 1 : 0, areq.config.c_str()); + if (pool_low_ > 0) + refill_pool(); + } + + 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): back to the pool if it came + // from there and can be recycled safely, otherwise tear it down + if (it->second.mode_shared) { + char resolved[PATH_MAX]; + if (realpath(it->second.config.c_str(), resolved) != nullptr) + shared_.erase(resolved); + } + if (it->second.pooled && return_to_pool(id, it->second)) { + devices_.erase(id); + code = 200; + msg = ublkd_msg_released(0); + LOG_INFO("ublkd: released /dev/ublkb` back to the pool", id); + return; + } + 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); + } + + // ---- warm pool internals ------------------------------------------------ + + std::string next_dev_tag() { + return "ublk-" + std::to_string(getpid()) + "-p" + std::to_string(tag_seq_++); + } + + // Take an idle pooled device whose fixed attributes match the image and + // hot-swap the image in. Returns nullptr when nothing matches (caller + // falls back to creating a device). Ownership of `real` transfers on + // success; on failure the caller releases it. + UblkDevice *take_pooled(ImageFile *real, int *slot_out) { + for (auto it = pool_idle_.begin(); it != pool_idle_.end(); ++it) { + UblkDevice *cand = it->dev; + // fixed-at-creation attributes must match (see the pool key) + if (cand->block_size() != real->block_size) + continue; + if (cand->dev_sectors() != + (real->num_lbas * (uint64_t)real->block_size) >> 9) + continue; + if (!cand->writable()) // first version pools writable devices only + continue; + ImageFile *old_file = nullptr; + ImageFileTarget *old_target = nullptr; + auto *new_target = ublk_make_image_target(real); + if (cand->swap_image(real, new_target, &old_file, &old_target) != 0) { + delete new_target; + // a device we cannot swap is not trustworthy: drop it + int slot = it->slot; + pool_idle_.erase(it); + cand->stop(); + cand->wait(); + cand->teardown(); + delete cand; + free_slots_.push_back(slot); + return nullptr; + } + *slot_out = it->slot; + pool_idle_.erase(it); + delete old_target; + delete old_file; // placeholder image released + return cand; + } + return nullptr; + } + + // Recycle a device: invalidate the block device's page cache, swap the + // placeholder image back in, and park it. Any doubt -> return false and + // let the caller tear the device down (safety over reuse). + bool return_to_pool(int dev_id, DevEntry &entry) { + if ((int)pool_idle_.size() >= pool_high_) + return false; + if (ublk_device_is_mounted(dev_id)) { + LOG_WARN("dev ` still mounted, not recycling it", dev_id); + return false; + } + if (ublk_device_flush_buffers(dev_id) != 0) + return false; // stale pages could leak to the next tenant + std::string ph_config; + if (ublk_pool_placeholder(placeholder_dir(), pool_size_gb_, entry.pool_slot, + ph_config) != 0) + return false; + ImageFile *ph = service_->create_image_file(ph_config.c_str(), next_dev_tag()); + if (ph == nullptr) + return false; + ImageFile *old_file = nullptr; + ImageFileTarget *old_target = nullptr; + auto *ph_target = ublk_make_image_target(ph); + UblkDevice *dev = entry.dev.release(); + if (dev->swap_image(ph, ph_target, &old_file, &old_target) != 0) { + delete ph_target; + delete ph; + entry.dev.reset(dev); // give it back so the caller tears it down + return false; + } + delete old_target; + delete old_file; // the tenant's image + pool_idle_.push_back({dev, entry.pool_slot}); + return true; + } + + std::string placeholder_dir() const { + std::string base = + (cache_base_.empty() ? "/opt/overlaybd/ublk_cache" : cache_base_) + + "/daemon/placeholders"; + mkdir(base.c_str(), 0755); + return base; + } + + // Bring the idle pool up to the low watermark. Synchronous and + // best-effort: creating a device is ~8ms, and this runs after a request + // has already been answered. + void refill_pool() { + while ((int)pool_idle_.size() < pool_low_) { + // each pooled device needs its own placeholder files + int slot; + if (!free_slots_.empty()) { + slot = free_slots_.back(); + free_slots_.pop_back(); + } else { + slot = next_slot_++; + } + std::string ph_config; + if (ublk_pool_placeholder(placeholder_dir(), pool_size_gb_, slot, + ph_config) != 0) { + free_slots_.push_back(slot); + return; + } + auto *dev = new UblkDevice(service_); + UblkDeviceOpts opts; + opts.image_config_path = ph_config; + opts.cache_dir = cache_base_; + if (dev->start(opts) != 0) { + delete dev; + free_slots_.push_back(slot); + LOG_WARN("pool refill: device start failed, pool stays at `", + (int)pool_idle_.size()); + return; + } + LOG_INFO("pool refill: /dev/ublkb` parked (slot `, idle `)", + dev->dev_id(), slot, (int)pool_idle_.size() + 1); + pool_idle_.push_back({dev, slot}); + } + } + + ImageService *service_; + std::string cache_base_; + std::map devices_; + // realpath(image config) -> dev_id of its shared device + std::map shared_; + // realpath'd image configs with an add in progress (UX guard, see above) + std::set adds_in_flight_; + // warm pool: idle devices parked on placeholder images + struct PooledDev { + UblkDevice *dev; + int slot; // its own placeholder file set + }; + std::vector pool_idle_; + std::vector free_slots_; // slots of devices that went away + int next_slot_ = 0; + int pool_low_ = 0; // 0 = pooling disabled + int pool_high_ = 0; + uint64_t pool_size_gb_ = 0; + uint64_t pool_hits_ = 0, pool_misses_ = 0; + uint64_t tag_seq_ = 0; +}; + +// 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; + std::string cache_dir; + + 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)"); + 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"); + int pool_low = 0, pool_high = 0; + uint64_t pool_size_gb = 0; + app.add_option("--pool-low", pool_low, + "warm pool: keep at least N idle devices ready (0 = pooling " + "disabled, the default); exclusive acquires are then served " + "by hot-swapping the image into a pre-created device"); + app.add_option("--pool-high", pool_high, + "warm pool: never park more than N idle devices (default = low)"); + app.add_option("--pool-size-gb", pool_size_gb, + "warm pool: virtual size of pooled devices in GB; only images " + "of exactly this size (and matching block size, writable) can " + "be served from the pool"); + CLI11_PARSE(app, argc, argv); + + if (pool_low > 0 && pool_size_gb == 0) { + fprintf(stderr, "overlaybd-ublkd: --pool-low requires --pool-size-gb\n"); + return 1; + } + + 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); + } + + // shared cache tree + daemon flock + patched service config; + // 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(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, cache_dir); + g_shutdown_flag = &server->shutdown_requested; + if (pool_low > 0) { + server->configure_pool(pool_low, pool_high, pool_size_gb); + server->prewarm_pool(); + LOG_INFO("warm pool enabled: low ` high ` size ` GB", pool_low, + pool_high > pool_low ? pool_high : pool_low, pool_size_gb); + } + + 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; + unlink(patched_config.c_str()); + photon::fini(); + return 0; +} diff --git a/src/ublk/ublkd_protocol.cpp b/src/ublk/ublkd_protocol.cpp new file mode 100644 index 00000000..762d6ddf --- /dev/null +++ b/src/ublk/ublkd_protocol.cpp @@ -0,0 +1,229 @@ +/* + 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; +} + +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; +} + +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); + 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_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(); + 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); + 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); + 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); +} + +std::string ublkd_msg_pool(int low, int high, int idle, uint64_t size_gb, + uint64_t hits, uint64_t misses) { + rapidjson::Document doc; + doc.SetObject(); + auto &a = doc.GetAllocator(); + doc.AddMember("ok", true, a); + doc.AddMember("enabled", low > 0, a); + doc.AddMember("low", low, a); + doc.AddMember("high", high, a); + doc.AddMember("idle", idle, a); + doc.AddMember("size_gb", size_gb, a); + doc.AddMember("hits", hits, a); + doc.AddMember("misses", misses, a); + return dump(doc); +} diff --git a/src/ublk/ublkd_protocol.h b/src/ublk/ublkd_protocol.h new file mode 100644 index 00000000..84d85a07 --- /dev/null +++ b/src/ublk/ublkd_protocol.h @@ -0,0 +1,83 @@ +/* + 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). 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) + 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" + 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) +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); + +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 + 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,...} +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); +// GET /v1/pool: warm pool state; low == 0 means pooling is disabled +std::string ublkd_msg_pool(int low, int high, int idle, uint64_t size_gb, + uint64_t hits, uint64_t misses);