moon-nbd

    A MoonBit Network Block Device client

    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    14 hours ago
    Downloads
    3

    #MoonNBD

    持续集成

    MoonNBD 是用 MoonBit 编写的网络块设备(NBD)客户端与服务端。客户端可以访问远端镜像、读取和修改数据、复制镜像、比较差异及检查数据完整性;服务端可以将内存或本地普通文件作为镜像导出,并与 QEMU 互通。

    项目仍在开发,尚未发布到 Mooncakes。当前支持 native 后端和 TCP,每条连接一次只处理一个请求。项目不支持 TLS、身份认证、同一连接上的并发请求或旧式握手。命令行服务端只监听 127.0.0.1,不应直接暴露在不可信网络中。

    #环境准备

    需要 MoonBit 0.10.18 或兼容版本、C 编译器,以及 moonbitlang/async 依赖。克隆仓库后,在项目根目录执行:

    moon update moon check --target native --deny-warn moon test --target native --deny-warn

    构建命令行程序:

    moon build --target native cmd/nbd

    #端到端使用流程

    下面以本地文件镜像为例,展示创建镜像、启动服务、查询信息、导出和比对的完整过程。先在 Linux 或 WSL 安装 QEMU 工具,并在仓库根目录打开两个终端。

    在终端一创建一个新的 16 MiB 镜像,然后启动只读服务端:

    qemu-img create -f raw demo.raw 16M moon run --target native cmd/nbd -- serve demo.raw 11091

    服务端会保持运行并监听本机 127.0.0.1:11091。该示例以只读方式导出文件。

    在终端二查询导出信息,再将镜像完整导出到一个新文件:

    moon run --target native cmd/nbd -- info 127.0.0.1 11091 moon run --target native cmd/nbd -- export 127.0.0.1 11091 backup.raw qemu-img compare demo.raw backup.raw

    info 显示镜像大小和能力。导出命令使用独占创建,不会覆盖已有的 backup.raw;导出过程中失败时可能留下不完整文件。比对命令成功退出表示两个镜像内容一致。完成后在终端一按 Ctrl+C 停止服务端。

    也可以把本地镜像导入到远端可写导出,或者校验本地镜像与远端是否一致:

    # 终端一用 --write 启动服务端 moon run --target native cmd/nbd -- serve demo.raw 11091 --write # 终端二导入一个与导出大小相同的本地镜像 moon run --target native cmd/nbd -- import 127.0.0.1 11091 replacement.raw # 校验远端镜像和本地文件,数据不同会以失败状态退出 moon run --target native cmd/nbd -- verify 127.0.0.1 11091 replacement.raw

    导入会按块发送数据,并在服务端支持时执行 FLUSH。中途失败可能已有部分数据写入远端;执行前应确认服务端导出可写且目标数据允许被覆盖。

    若确实要允许客户端写入文件镜像,启动时必须明确加上 --write:

    moon run --target native cmd/nbd -- serve demo.raw 11091 --write

    写入、刷盘、TRIM 或写零请求发送后若连接中断,客户端会报告 OutcomeUnknown。服务端可能已经执行该操作,调用方应先检查数据,再决定后续处理,不要盲目重试。

    #命令行参考

    命令均在仓库根目录执行:

    # 列出服务端导出 moon run --target native cmd/nbd -- list 127.0.0.1 10809 # 查询默认导出信息 moon run --target native cmd/nbd -- info 127.0.0.1 10809 # 查看整个镜像的分配状态(需要对端支持 base:allocation) moon run --target native cmd/nbd -- allocation 127.0.0.1 10809 0 16777216 # 从偏移 0 读取 512 字节到新文件 moon run --target native cmd/nbd -- read 127.0.0.1 10809 0 512 sector.bin # 完整导出到新文件 moon run --target native cmd/nbd -- export 127.0.0.1 10809 backup.raw # 根据对端分配信息稀疏导出 moon run --target native cmd/nbd -- export-sparse 127.0.0.1 10809 sparse-backup.raw # 将同尺寸本地镜像导入可写远端 moon run --target native cmd/nbd -- import 127.0.0.1 10809 image.raw # 比较本地镜像和远端导出 moon run --target native cmd/nbd -- verify 127.0.0.1 10809 image.raw # 以只读方式导出本地镜像 moon run --target native cmd/nbd -- serve demo.raw 11091

    info、read、两种导出命令、import 和 verify 可在末尾追加导出名称。info 通过 NBD_OPT_INFO 查询,不会进入数据传输阶段。服务端可用 --name NAME 设置导出名称,默认只读;只有显式指定 --write 才允许远端修改底层文件。

    allocation 命令的参数依次为偏移和长度,输出服务端报告的空洞区段、明确为零的区段及其他状态的字节数和区段数。空洞与零标记可以重叠;“其他状态”只表示服务端没有返回这两种标记,不表示内容一定非零。可在长度后追加导出名称。

    稀疏导出要求对端支持 base:allocation。它只跳过服务端明确标记为 ZERO 的区段;单独标记为 HOLE 的区段仍会读取,以免错误地把未知数据变成零。稀疏导出目标同样必须是新文件,源镜像在导出过程中应保持不变。

    #MoonBit 库接口

    let disk = @nbd.Client::connect("127.0.0.1", 10809, b"")
    let data = disk.read_at(0UL, 512)
    disk.disconnect()

    客户端支持命名导出、导出列表、结构化读取、base:allocation 块状态、读写、刷盘、TRIM、写零、缓存提示和主动断开。单次读写最大为 16 MiB;操作默认超时 30 秒,可通过 timeout_ms 调整。服务端支持内存和本地普通文件后端。

    备份与校验相关接口包括:

    • copy_to_file:分块完整复制到空文件。
    • copy_from_file:把同尺寸本地镜像分块写入远端导出,并在支持时刷盘。
    • read_bytes:读取任意字节边界的小范围数据,自动按服务端最小块要求对齐请求。
    • compare_file:逐字节比较同尺寸本地镜像与远端指定范围,返回合并后的差异区间。
    • copy_file_ranges 和 copy_differences_from_file:将本地指定区间或差异区间写入远端,写前验证区间计划。
    • copy_sparse_to_file:根据对端明确报告为零的区段稀疏复制。
    • copy_ranges_to_file:将指定范围写入同尺寸的本地镜像,并在写入前检查全部范围。
    • copy_differences_to_file:比较两个稳定的远端镜像,并将差异范围复制到本地镜像。差异会扩展到合法块边界,复制范围可能大于实际变化范围。
    • compare_ranges:比较两个远端镜像并返回差异区间。
    • allocation_summary:汇总指定范围内服务端报告的空洞、已知零和其他状态。
    • create_integrity_manifest 和 verify_integrity_manifest:生成及检查分块 CRC32 清单。CRC32 可检测意外损坏,但不提供防篡改保证。
    • RangeSet:校验、排序和合并范围,并提供并集、交集、差集操作。
    • CachedClient:为稳定的只读镜像提供容量受限的 LRU 读取缓存。缓存不会并发请求;通过该客户端执行写入、TRIM 和写零时,会清除重叠缓存行。
    • ClientConfig::read_at_retry:在稳定的只读导出发生传输故障时重连并重试读取,不会重试修改操作。

    增量复制要求调用方保证本地镜像确实对应比较所用的基线。操作中途发生 I/O 错误时,目标可能只完成部分更新;该接口不提供原子替换或自动基线验证。文件服务端也要求调用方确保底层文件不会被其他程序并发改写。内存后端的数据在进程退出后丢失。

    #持续集成与本地联调

    GitHub Actions 会在推送、拉取请求和手动触发时运行以下流程:更新依赖、检查、构建、测试、格式检查,并运行 QEMU 互操作脚本。CI 工作流位于 .github/workflows/ci.yml,状态显示在本页顶部。

    在 Linux 或 WSL 安装 qemu-utils 后,可在本地逐项运行:

    bash scripts/qemu-interop.sh bash scripts/qemu-server-interop.sh bash scripts/qemu-file-server-interop.sh bash scripts/qemu-export-interop.sh bash scripts/qemu-large-offset-interop.sh bash scripts/qemu-sparse-export-interop.sh

    这些脚本验证客户端连接 QEMU、QEMU 连接内存与文件服务端、完整和稀疏导出的字节一致性,以及 4 GiB 以上偏移。脚本会创建临时镜像并使用本机端口 11089 至 11094;端口被占用时,请先释放相应端口。

    #许可证

    项目使用 Apache-2.0 许可证,网络实现依赖 moonbitlang/async。NBD 协议参考官方规范,QEMU 仅作为互操作测试工具。

    NbdError

    pub suberror NbdError {
    Protocol(String)
    InvalidArgument(String)
    Server(UInt)
    OutcomeUnknown(String)
    LimitExceeded(String)
    }

    AllocationExtent

    pub(all) struct AllocationExtent {
    offset : UInt64
    length : UInt
    flags : UInt
    }

    One consecutive extent from the base:allocation context.

    AllocationExtent::is_hole

    fn AllocationExtent::is_hole(self : AllocationExtent) -> Bool

    AllocationExtent::is_zero

    fn AllocationExtent::is_zero(self : AllocationExtent) -> Bool

    AllocationSummary

    pub(all) struct AllocationSummary {
    range_bytes : UInt64
    hole_bytes : UInt64
    zero_bytes : UInt64
    other_bytes : UInt64
    hole_extents : Int
    zero_extents : Int
    other_extents : Int
    }

    Byte and extent counts returned by an allocation scan.

    hole_bytes and zero_bytes are independent flags and can overlap. other_bytes has neither flag set; it describes only what the server reported and does not assert that the bytes contain nonzero data.

    ByteRange

    pub(all) struct ByteRange {
    offset : UInt64
    length : UInt64
    }

    A non-empty half-open byte range: [offset, offset + length).

    ByteRange::contains

    fn ByteRange::contains(self : ByteRange, offset : UInt64) -> Bool

    ByteRange::end

    fn ByteRange::end(self : ByteRange) -> UInt64 raise

    ByteRange::intersects

    fn ByteRange::intersects(self : ByteRange, other : ByteRange) -> Bool

    CacheStats

    pub(all) struct CacheStats {
    hits : UInt64
    misses : UInt64
    evictions : UInt64
    entries : Int
    }

    Observable counters for a CachedClient instance.

    CachedClient

    pub struct CachedClient {
    // private fields
    }

    A bounded LRU cache around one NBD connection.

    It is intended for stable exports such as snapshots and read-mostly disk images. It deliberately keeps the single-request semantics of Client; callers must not use it concurrently. Mutating methods invalidate every overlapping cache line before sending a request.

    CachedClient::close

    fn CachedClient::close(self : CachedClient) -> Unit

    CachedClient::connect

    async fn CachedClient::connect(host : String, port : Int, export_name : Bytes, cache_bytes : Int, line_size? : Int, structured? : Bool, timeout_ms? : Int) -> CachedClient

    Connect and allocate a bounded cache.

    cache_bytes is a capacity budget, not an eager allocation. line_size defaults to the server's maximum request size capped at 1 MiB. A line must be a multiple of the negotiated minimum block size.

    CachedClient::disconnect

    async fn CachedClient::disconnect(self : CachedClient) -> Unit

    CachedClient::flush

    async fn CachedClient::flush(self : CachedClient) -> Unit

    CachedClient::line_size

    fn CachedClient::line_size(self : CachedClient) -> UInt

    CachedClient::read_at

    async fn CachedClient::read_at(self : CachedClient, offset : UInt64, length : Int) -> Bytes

    Read a range through the cache. Range validation matches Client::read_at.

    CachedClient::size

    fn CachedClient::size(self : CachedClient) -> UInt64

    CachedClient::stats

    fn CachedClient::stats(self : CachedClient) -> CacheStats

    CachedClient::trim

    async fn CachedClient::trim(self : CachedClient, offset : UInt64, length : Int) -> Unit

    CachedClient::write_at

    async fn CachedClient::write_at(self : CachedClient, offset : UInt64, data : Bytes) -> Unit

    CachedClient::write_zeroes

    async fn CachedClient::write_zeroes(self : CachedClient, offset : UInt64, length : Int, no_hole? : Bool) -> Unit

    ChunkMismatch

    pub(all) struct ChunkMismatch {
    offset : UInt64
    length : UInt
    expected : UInt
    actual : UInt
    }

    One chunk whose observed checksum differs from a manifest.

    Client

    pub struct Client {
    size : UInt64
    flags : UInt
    min_block : UInt
    max_block : UInt
    structured : Bool
    // private fields
    }

    Client::allocation_summary

    async fn Client::allocation_summary(self : Client, offset : UInt64, length : UInt64, max_extents? : Int) -> AllocationSummary

    Scan allocation metadata and total bytes by the flags the server reported.

    Servers may return a different number of extents for the same image, so the extent limit is explicit and bounded. The report is descriptive: it does not imply that a hole can be omitted from a byte-for-byte export.

    Client::block_status

    async fn Client::block_status(self : Client, offset : UInt64, length : Int) -> Array[AllocationExtent]

    Returns at least one extent. Servers may return less than the requested range.

    Client::cache

    async fn Client::cache(self : Client, offset : UInt64, length : Int) -> Unit

    Hint that a range will be read soon.

    NBD cache is advisory: a successful reply does not guarantee that data is resident locally, and callers must still issue read_at normally.

    Client::close

    fn Client::close(self : Client) -> Unit

    Client::compare_file

    async fn Client::compare_file(self : Client, file :
    File
    , offset : UInt64, length : UInt64, max_ranges? : Int) -> FileCompareStats

    Compare a local same-size image against a range of this export. Difference offsets are absolute export offsets and adjacent changed bytes are coalesced. Both inputs must remain stable during the comparison.

    Client::connect

    async fn Client::connect(host : String, port : Int, export_name : Bytes, structured? : Bool, allocation? : Bool, timeout_ms? : Int) -> Client

    Client::copy_differences_from_file

    async fn Client::copy_differences_from_file(self : Client, file :
    File
    , offset : UInt64, length : UInt64, max_ranges? : Int) -> UInt64

    Import only the changed parts of a local image into this export. Changed bytes expand to the export's minimum block boundaries so each NBD write remains valid. The caller must ensure both inputs remain stable.

    Client::copy_file_ranges

    async fn Client::copy_file_ranges(self : Client, file :
    File
    , ranges : RangeSet) -> UInt64

    Copy selected ranges from a same-size local image into the export. All ranges are validated before the first remote write.

    Client::copy_from_file

    async fn Client::copy_from_file(self : Client, file :
    File
    ) -> UInt64

    Write a local regular file into the remote export.

    The file must have exactly the export's logical size. On failure, earlier requests may already have been applied. The file and export must remain stable during the copy. If supported by the server, FLUSH is issued at the end; otherwise the acknowledged writes are left in the server's cache.

    Client::copy_ranges_to_file

    async fn Client::copy_ranges_to_file(self : Client, file :
    File
    , ranges : RangeSet) -> RangeCopyStats

    Copy selected ranges into an existing same-size regular file.

    Unlike copy_to_file, this operation intentionally preserves every byte outside ranges; it is suitable for updating a previously created image. The caller must ensure that the destination represents the desired baseline and that the remote export is stable throughout the copy.

    Client::copy_sparse_to_file

    async fn Client::copy_sparse_to_file(self : Client, file :
    File
    , max_extents? : Int) -> SparseCopyStats

    Copy an export using the negotiated base:allocation context.

    The destination must be a new empty regular file. It is first extended to the logical export size, then only extents not explicitly marked ZERO are written. This preserves byte-for-byte contents while allowing sparse-file aware filesystems to avoid allocating known-zero regions. The remote export must not change during the operation. A failed operation leaves a partial target and the caller owns that target.

    Client::copy_to_file

    async fn Client::copy_to_file(self : Client, file :
    File
    ) -> UInt64

    Copy the complete export to an empty regular file in bounded chunks. A failed operation may leave a partial output file; the caller owns it.

    Client::create_integrity_manifest

    async fn Client::create_integrity_manifest(self : Client, chunk_size : Int, max_chunks? : Int) -> IntegrityManifest

    Build an integrity manifest by reading every byte of a stable export.

    The requested chunk size must fit in a single negotiated NBD read. The last manifest chunk may be smaller than chunk_size.

    Client::disconnect

    async fn Client::disconnect(self : Client) -> Unit

    Client::flush

    async fn Client::flush(self : Client) -> Unit

    Client::range_set

    fn Client::range_set(self : Client, input : Array[ByteRange]) -> RangeSet raise

    Validate ranges for one client and normalize them with its block alignment.

    Client::read_at

    async fn Client::read_at(self : Client, offset : UInt64, length : Int) -> Bytes

    Client::read_bytes

    async fn Client::read_bytes(self : Client, offset : UInt64, length : Int) -> Bytes

    Read an arbitrary byte range by rounding wire requests to block boundaries.

    NBD servers can require aligned offsets and lengths. This helper reads the surrounding blocks and returns only the requested bytes.

    Client::scan_allocation

    async fn Client::scan_allocation(self : Client, offset : UInt64, length : UInt64, max_extents? : Int) -> Array[AllocationExtent]

    Query a complete range, continuing when the server returns only a prefix. Returned extents are clipped to the requested range and capped by max_extents.

    Client::trim

    async fn Client::trim(self : Client, offset : UInt64, length : Int) -> Unit

    Inform the server that a region is no longer needed; subsequent data is unspecified.

    Client::verify_integrity_manifest

    async fn Client::verify_integrity_manifest(self : Client, manifest : IntegrityManifest, max_mismatches? : Int) -> Array[ChunkMismatch]

    Re-read an export and return every chunk that differs from manifest.

    The manifest does not include an identity or generation counter, so callers must ensure it is checked against the intended stable export.

    Client::write_at

    async fn Client::write_at(self : Client, offset : UInt64, data : Bytes) -> Unit

    Client::write_zeroes

    async fn Client::write_zeroes(self : Client, offset : UInt64, length : Int, no_hole? : Bool) -> Unit

    Write zeroes without transferring a zero-filled payload.

    ClientConfig

    pub(all) struct ClientConfig {
    host : String
    port : Int
    export_name : Bytes
    structured : Bool
    allocation : Bool
    timeout_ms : Int
    }

    Reusable immutable connection settings for read-only retry workflows.

    ClientConfig::connect

    async fn ClientConfig::connect(self : ClientConfig) -> Client

    ClientConfig::new

    fn ClientConfig::new(host : String, port : Int, export_name : Bytes, structured? : Bool, allocation? : Bool, timeout_ms? : Int) -> ClientConfig raise

    ClientConfig::read_at_retry

    async fn ClientConfig::read_at_retry(self : ClientConfig, offset : UInt64, length : Int, retries? : Int) -> Bytes

    Read once or reconnect and retry after a transport failure.

    This function is safe only for a stable export: a reconnect can observe a different version of mutable storage. It never retries writes, flushes, trims, or write-zeroes because their outcome can be unknown after a lost connection. retries counts additional attempts after the first one.

    Difference

    pub(all) struct Difference {
    offset : UInt64
    length : UInt64
    }

    A half-open byte range containing differences between two exports.

    ExportEntry

    pub(all) struct ExportEntry {
    name : Bytes
    description : Bytes
    }

    Export names and descriptions are preserved as protocol bytes.

    FileCompareStats

    pub(all) struct FileCompareStats {
    bytes_checked : UInt64
    differences : Array[Difference]
    }

    Result of comparing a local image with a remote export.

    FileExport

    pub struct FileExport {
    // private fields
    }

    FileExport::close

    fn FileExport::close(self : FileExport) -> Unit

    FileExport::open

    async fn FileExport::open(path : String, read_only? : Bool, name? : Bytes) -> FileExport

    Open an existing regular file without truncation. The owner must keep it stable.

    FileExport::serve_connection

    async fn FileExport::serve_connection(self : FileExport, tcp :
    Tcp
    ) -> Unit

    Serves one connection using an existing file; caller closes both resources.

    FileExport::size

    fn FileExport::size(self : FileExport) -> UInt64

    IntegrityManifest

    pub(all) struct IntegrityManifest {
    export_size : UInt64
    chunk_size : UInt
    checksums : Array[UInt]
    }

    A portable CRC32 manifest for a stable NBD export.

    CRC32 detects accidental corruption; it is not a cryptographic signature and must not be used to authenticate an untrusted image.

    IntegrityManifest::decode

    fn IntegrityManifest::decode(data : Bytes) -> IntegrityManifest raise

    Decode and validate an integrity manifest produced by encode.

    IntegrityManifest::encode

    fn IntegrityManifest::encode(self : IntegrityManifest) -> Bytes raise

    Encode a manifest in a small deterministic binary format.

    Layout: eight-byte magic, u32 version, u64 size, u32 chunk size, u32 count, then count big-endian u32 CRC32 values.

    MemoryExport

    pub struct MemoryExport {
    // private fields
    }

    An in-memory export. Its data is volatile and is lost when the process exits.

    MemoryExport::new

    fn MemoryExport::new(size : Int, read_only? : Bool, name? : Bytes) -> MemoryExport raise

    MemoryExport::serve_connection

    async fn MemoryExport::serve_connection(self : MemoryExport, tcp :
    Tcp
    ) -> Unit

    Serves one connected socket until the client disconnects or an error occurs. The caller retains ownership of the socket and should close it afterwards.

    MemoryExport::size

    fn MemoryExport::size(self : MemoryExport) -> UInt64

    MemoryExport::snapshot

    fn MemoryExport::snapshot(self : MemoryExport) -> Bytes

    RangeCopyStats

    pub(all) struct RangeCopyStats {
    ranges : Int
    bytes : UInt64
    }

    Result of copying an explicit set of source ranges to an existing image.

    RangeSet

    pub struct RangeSet {
    // private fields
    }

    Canonical non-overlapping, non-adjacent ranges with one alignment contract.

    RangeSet::alignment

    fn RangeSet::alignment(self : RangeSet) -> UInt64

    RangeSet::contains

    fn RangeSet::contains(self : RangeSet, offset : UInt64) -> Bool

    RangeSet::intersection

    fn RangeSet::intersection(self : RangeSet, other : RangeSet) -> RangeSet raise

    Return the overlapping portion of two range sets.

    RangeSet::is_empty

    fn RangeSet::is_empty(self : RangeSet) -> Bool

    RangeSet::new

    fn RangeSet::new(input : Array[ByteRange], alignment? : UInt64, max_ranges? : Int) -> RangeSet raise

    Normalize, sort and merge a collection of aligned ranges.

    RangeSet::ranges

    fn RangeSet::ranges(self : RangeSet) -> Array[ByteRange]

    RangeSet::subtract

    fn RangeSet::subtract(self : RangeSet, other : RangeSet) -> RangeSet raise

    Return bytes in self that are not covered by other.

    RangeSet::total_length

    fn RangeSet::total_length(self : RangeSet) -> UInt64

    RangeSet::union

    fn RangeSet::union(self : RangeSet, other : RangeSet) -> RangeSet raise

    Return the union of two range sets.

    RemoteExportInfo

    pub(all) struct RemoteExportInfo {
    size : UInt64
    flags : UInt
    }

    Information obtained with NBD_OPT_INFO, without entering transmission mode.

    SparseCopyStats

    pub(all) struct SparseCopyStats {
    logical_bytes : UInt64
    data_bytes : UInt64
    zero_bytes : UInt64
    extents : Int
    }

    Summary of a completed sparse export.

    data_bytes is the number of source bytes written to the target. Aligned reads may transfer more bytes. zero_bytes was omitted only when the server explicitly described it as reading as zero; a hole alone is deliberately not enough to omit it.

    compare_ranges

    async fn compare_ranges(left : Client, right : Client, offset : UInt64, length : UInt64, max_ranges? : Int) -> Array[Difference]

    Compare a range from two NBD clients, returning exact contiguous differing bytes. Sources must remain stable throughout the comparison. This scans all requested bytes and raises when the result limit is exceeded rather than silently truncating.

    copy_differences_to_file

    async fn copy_differences_to_file(source : Client, baseline : Client, file :
    File
    , offset : UInt64, length : UInt64, max_ranges? : Int) -> RangeCopyStats

    Compare two stable NBD exports and update only differing bytes in a local same-size image. source supplies replacement data while baseline is read only for comparison; all three artefacts must remain stable during the operation.

    inspect_export

    async fn inspect_export(host : String, port : Int, name : Bytes, timeout_ms? : Int) -> RemoteExportInfo

    Inspect an export without acquiring a transmission-mode connection.

    This is useful for status commands and preflight checks. It asks only for NBD_INFO_EXPORT; servers may send other informational replies, which are ignored after their framing is validated.

    list_exports

    async fn list_exports(host : String, port : Int, timeout_ms? : Int) -> Array[ExportEntry]

    List exports over a separate connection without entering transmission mode.