commit 1d3c32ea7bda042a6887d55d9ca61e16089bf5a7 Author: jabberwocky238 <7176656@qq.com> Date: Tue Jul 7 00:07:58 2026 +0800 phase1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b87e64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +etvd_fuse +src/*.o +backend/ +mnt/ +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bd564f6 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +CXX ?= g++ +CXXFLAGS ?= -std=c++17 -O2 -g -Wall -Wextra -D_GNU_SOURCE -D_FILE_OFFSET_BITS=64 -pthread +FUSE_CFLAGS := $(shell pkg-config --cflags fuse3) +FUSE_LIBS := $(shell pkg-config --libs fuse3) + +TARGET := etvd_fuse +SRCS := src/main.cpp src/fuse_ops.cpp +OBJS := $(SRCS:.cpp=.o) +HEADERS := $(wildcard src/*.h) + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(OBJS) + $(CXX) $(CXXFLAGS) $(OBJS) -o $@ $(FUSE_LIBS) -pthread + +src/%.o: src/%.cpp $(HEADERS) + $(CXX) $(CXXFLAGS) $(FUSE_CFLAGS) -c $< -o $@ + +clean: + rm -f $(OBJS) $(TARGET) diff --git a/README.md b/README.md new file mode 100644 index 0000000..143c298 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# etvd — 第一阶段:FUSE 拦截验证 + +> 对应 `docs/DESIGN.md` 第一阶段。目标:让 FUSE Daemon 挂载目录、拦截所有文件系统调用,上层程序能正常 open / read / write / close,并能在底层结构化观察到每一次写入,证明通路打通。 + +## 这一步做了什么 + +- 一个基于 **libfuse3** 的 C++ FUSE Daemon(`etvd_fuse`),挂载一个目录,拦截全部文件系统调用。 +- **透传后端(passthrough)**:所有操作转发到真实后端目录(默认 `./backend`)。这是第一阶段的后端;第二阶段会替换为 COW append-only 后端(`data.raw` + Segment + `cur` 快照),FUSE 拦截层与日志层保持不变。 +- **操作日志层**:每一次拦截到的调用都结构化打印到 stderr(`[etvd] `),`write` 额外带 `off/size/rc` 与前 16 字节 hex 预览 —— 满足「从底层观察到每一次写入」。 +- 回调里直接 log + syscall 透传,**没有额外抽象层**(FUSE3 自己的 `struct fuse_operations` 就是接口)。第二阶段做 COW 后端时再按需引入存储抽象 —— 届时 `write` 语义是 append+Segment,和现在的 pwrite 形状不同,现在猜接口多半对不上。 + +### 三层结构 + +``` +上层程序 (bash / cat / dd / sqlite3) + │ 标准 Linux 文件 API + ▼ +FUSE 拦截层 (src/fuse_ops.cpp) ── 每次调用先记日志,再直接 syscall 透传到 ./backend +``` + +## 依赖 + +- Linux + `/dev/fuse`(内核 FUSE 模块) +- `libfuse3-dev`(头文件 + `fuse3.pc`) +- `pkg-config`、`g++`(C++17)、`make` + +安装依赖(Ubuntu/Debian): + +```bash +sudo apt-get install -y libfuse3-dev pkg-config g++ make +``` + +## 构建 + +```bash +make +``` + +产出可执行文件 `etvd_fuse`。 + +## 运行 + +```bash +# 挂载到 mnt/,后端目录 ./backend(两者不存在会自动创建) +./etvd_fuse mnt -o backend=./backend +``` + +- `-o backend=DIR`:指定后端目录(默认 `./backend`)。 +- `-f`:前台运行(**已强制开启**,便于日志输出与 Ctrl-C 卸载)。 +- `-o default_permissions`:**已强制开启**,内核按透传的 mode 做权限校验。 +- 日志输出到 stderr。 + +### 卸载 + +```bash +fusermount3 -u mnt +``` + +## 验收测试 + +```bash +# 1. 启动 daemon(前台,日志重定向到文件) +./etvd_fuse mnt -o backend=./backend 2>etvd.log & + +# 2. 基本读写 +echo "hello world" > mnt/a.txt +cat mnt/a.txt # -> hello world +echo "second line" >> mnt/a.txt + +# 3. 块写 / 目录 / 重命名 / 删除 / 截断 +dd if=/dev/zero of=mnt/b.bin bs=4096 count=4 status=none +mkdir mnt/sub && ls -F mnt +mv mnt/a.txt mnt/a.log +rm mnt/b.bin +truncate -s 100 mnt/a.log + +# 4. SQLite 冒烟(真实数据库负载,无感知运行) +sqlite3 mnt/test.db "CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT); \ + INSERT INTO t VALUES (1,'alice'); INSERT INTO t VALUES (2,'bob'); SELECT * FROM t;" +sqlite3 mnt/test.db "UPDATE t SET name='ALICE' WHERE id=1; SELECT * FROM t;" +sqlite3 mnt/test.db "PRAGMA integrity_check;" + +# 5. 观察底层拦截日志 +grep '\[etvd\] write' etvd.log # 每次 write 的 off/size/hex 预览 +grep '/test.db' etvd.log | grep write # 随机页覆盖写(off=0 / off=4096) +grep -E 'create|unlink' etvd.log | grep journal # rollback journal 生命周期 +grep -c fsync etvd.log # 事务刷盘调用 + +# 6. 卸载 +fusermount3 -u mnt +``` + +### 已验证的写入语义 + +| 场景 | 行为 | 日志可见 | +|------|------|----------| +| 追加写 | `echo >>` | `write off=<文件末尾> size=N` | +| 块写 | `dd bs=4k count=4` | 4 次 `write off=0/4096/8192/12288 size=4096` | +| 随机覆盖写 | SQLite `UPDATE` 改页 | `write off=4096 size=4096`(页 1 原地覆盖) | +| 截断 | `truncate -s 100` | `truncate size=100` | +| journal | SQLite 事务 | `create /test.db-journal` → `unlink` 每事务一次 | +| 刷盘 | SQLite `COMMIT` | `fsync` | + +## 实现要点 + +- **绝对路径**:libfuse3 在跑事件循环前会 `chdir("/")`,因此 daemon 在请求时解析的所有路径(后端根目录)必须是绝对路径。`main` 在挂载前把 `backend` 与 `mountpoint` 都 `realpath` 成绝对路径。 +- **文件句柄**:`open/create` 打开真实 fd 存入 `fuse_file_info->fh`;`read/write` 用 `pread/pwrite`;`release` 关闭。 +- **锁**:`lock` 回调对 `F_GETLK` 返回 `F_UNLCK`、对 `F_SETLK` 返回 0,使 SQLite 的 fcntl 建议锁在单进程下正常运行。**真实互斥留到第十阶段的分布式写租约。** +- **未实现的 op**(xattr / fallocate 等)保持 NULL,FUSE 返回 `-ENOSYS`,SQLite 等会自动回退。 + +## 第一阶段边界(明确不做) + +COW / append-only、Segment / VersionDiff、`cur` 快照、KV 持久化、前缀策略、MVCC、Raft、租约、多节点、QUIC —— 全部留到后续阶段。当前唯一目标是证明 FUSE 拦截通路打通,且能观测每一次写入。 + +## 文件 + +``` +src/logger.h 线程安全操作日志 + hex 预览 +src/fuse_ops.h/.cpp FUSE3 回调:日志 + 直接 syscall 透传 + ops 表 +src/main.cpp 参数解析(fuse_opt)、绝对路径处理、挂载入口 +Makefile pkg-config fuse3 +``` diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..238650c --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,716 @@ +# etvd设计文档 + +> 分十阶段递进实现:FUSE 拦截 -> 内存段 Diff + 聚合快照 -> SQLite 真实写入记录 -> KV 持久化 -> KV 事务模式 -> Prefix 策略 -> MVCC 恢复 -> etcd 协议兼容 -> QUIC mTLS Raft -> 分布式租约。 + +--- + +## 目录 + +1. [第一阶段:FUSE 拦截验证](#第一阶段fuse-拦截验证) +2. [第二阶段:内存段 Diff + 聚合快照](#第二阶段内存段-diff--聚合快照) +3. [第三阶段:SQLite 真实写入记录](#第三阶段sqlite-真实写入记录) +4. [第四阶段:KV 持久化元数据](#第四阶段kv-持久化元数据) +5. [第五阶段:KV 事务模式](#第五阶段kv-事务模式) +6. [第六阶段:KV Prefix 策略层](#第六阶段kv-prefix-策略层) +7. [第七阶段:基于 KV 的 MVCC 恢复](#第七阶段基于-kv-的-mvcc-恢复) +构建为lib和单独可执行器 +8. [第八阶段:etcd 协议兼容](#第八阶段etcd-协议兼容) +9. [第九阶段:QUIC mTLS Raft 算法](#第九阶段quic-mtls-raft-算法) +10. [第十阶段:分布式租约](#第十阶段分布式租约) +11. [后续分布式扩展蓝图](#后续分布式扩展蓝图) + +--- + +## 第一阶段:FUSE 拦截验证 + +**目标**:让 FUSE Daemon 能挂载目录,拦截所有文件系统调用,上层程序可以正常 open / read / write / close,证明通路打通。 + +### 本阶段边界(明确不做什么) + +| 排除项 | 原因 | +|--------|------| +| 分布式共识 | 第一步只验证单节点文件语义是否正确 | +| Raft 同步 | 元数据先放内存或简单持久化,不碰共识 | +| 节点间租约 | 单节点不存在并发写冲突 | +| 多节点 QUIC 同步 | 无集群,无需网络传输 | +| 列族分流 / 策略层持久化 | 前缀策略先放内存,不依赖底层存储引擎的列族能力 | +| 复杂 GC / 归档 | 先让系统能跑起来,后期再补空洞回收 | + +当前唯一目标:让 SQLite 能正常读写一个挂在 FUSE 上的文件,且我们能从底层观察到每一次写入产生的「内存段 Diff」。 + +--- + +--- + +## 第二阶段:内存段 Diff + 聚合快照 + +**目标**:在 FUSE 拦截通路打通的基础上,实现 COW 内存段 Diff 机制。任何写入都不原地覆盖物理文件,只在尾部追加新字节段,通过段链表拼接还原文件视图。 + +### 三层架构 + +**FUSE 拦截层** +- 挂载 /vfs,所有文件操作 100% 经过 FUSE Daemon。 +- 拦截 open、read、write、pwrite、lseek、fsync、truncate、ftruncate、close。 +- 写入拦截是核心:任何 write 不能原地覆盖物理文件已有字节,必须触发内存段 Diff 机制。 + +**内存段 Diff 管理层** +- 物理存储是一个单全局稀疏大文件(data.raw),只允许顺序追加,绝不原地修改已有字节。 +- 每次 write 操作,新增数据追加到 data.raw 尾部,生成一个物理偏移区间,称为 Segment。 +- 一个虚拟文件的当前内容,由一组 Segment 按逻辑顺序拼接而成。 +- 一次事务(或一次 fsync 边界)产生的所有 Segment 变更,打包为一个 VersionDiff。 + +**两层分离原则** +- data.raw 只存原始二进制字节,不存任何元数据、版本号、路径信息。 +- 元数据只存 Segment 的偏移区间和逻辑拼接顺序,不存文件内容本身。 + +**稀疏文件物理存储** +- 单文件 data.raw,预分配为稀疏文件,支持 fallocate(Linux)或 truncate(macOS)。 +- 唯一写入接口:Append,返回写入后的起始偏移和长度。 +- 读取接口:按偏移区间随机读。 + +--- + +### 三种写入场景的设计 + +### 场景一:Append 追加 + +用户行为示例:echo "new line" >> /vfs/log.txt + +底层行为: +1. FUSE write 拦截到追加请求。 +2. 将新内容顺序写入 data.raw 尾部,得到一个物理段,如从偏移 1M 到 1M+256 字节。 +3. 生成 VersionDiff,记录本次新增的物理段区间。 +4. 更新该虚拟文件的当前视图:原视图段链表 + 新增段。 +5. 读取时按段链表顺序拼接,用户看到的就是追加后的完整文件。 + +关键点:Append 是最简单场景,物理文件只做尾部追加,段链表也只做尾部追加,无需替换中间段。 + +### 场景二:随机覆盖写 + +用户行为示例:dd seek=4096 of=/vfs/config.cfg bs=1024 count=1 + +底层行为: +1. FUSE 拦截到对文件中间偏移 4096 位置的写入。 +2. 绝不原地修改 data.raw 中 4096 位置已有的字节。 +3. 将新的 1KB 内容追加到 data.raw 尾部,得到新物理段。 +4. 生成 VersionDiff,记录新增物理段。 +5. 更新虚拟文件视图:将原视图中被覆盖的逻辑区间替换为新物理段,其余段不变。 +6. 读取时按新视图拼接,用户看到覆盖后的内容。 + +关键点:随机写看似改了文件中间,实际底层全是追加,旧字节仍在原处不动,只是视图链表中不再引用它。 + +### 场景三:Truncate 截断 + +用户行为示例:truncate -s 2048 /vfs/cache.db + +底层行为: +1. FUSE 拦截截断请求。 +2. 纯截断不产生新物理字节,data.raw 不增长。 +3. 生成 VersionDiff,标记操作类型为 Truncate,记录目标文件尺寸 2048。 +4. 更新虚拟文件视图:遍历原段链表,只保留逻辑偏移在 2048 以内的段,裁剪掉超出部分。 +5. 被裁剪掉的旧段不再出现在当前视图,但物理字节仍在 data.raw 中,等待后期 GC 回收。 + +关键点:Truncate 只是修改视图链表的长度,不动物理文件。 + +### 场景四:扩展写(写操作超出当前文件末尾) + +用户行为示例:dd if=/data.bin of=/vfs/target.bin bs=1024 seek=100 + +底层行为: +1. FUSE 拦截到对偏移 100KB 位置的写入,当前文件只有 50KB。 +2. 将新内容追加到 data.raw 尾部,得到新物理段。 +3. 更新虚拟文件视图:原 0-50KB 段保持不变,50KB-100KB 区间逻辑上视为零填充空洞,100KB 之后挂入新物理段。 +4. 读取时遇到空洞区间返回零字节,遇到有段区间返回实际数据。 + +关键点:扩展写本质上是随机覆盖写的一种边界情况,文件中间可能出现未写入的空洞,读取时必须正确处理零填充。 + +### 场景五:Fsync 刷盘边界与事务圈定 + +用户行为示例:sqlite > BEGIN; INSERT ...; COMMIT; + +底层行为: +1. BEGIN 到 COMMIT 之间的多次 write 全部暂存在事务缓冲区,不立即 Append 到 data.raw。 +2. 缓冲期间不生成 VersionDiff,不更新 cur 快照。 +3. COMMIT(或 fsync)触发 TxnEnd,将缓冲区中所有变更一次性 Append 到 data.raw。 +4. 生成单条 VersionDiff,包含本次事务的所有新增 Segment。 +5. 更新 cur 快照,事务内所有变更对外原子可见。 + +关键点:事务边界决定 Diff 的打包粒度,一次事务对应一个版本,保证原子性。 + +--- + +### cur 快照与读取加速 + +### 为什么需要 cur 快照 + +如果每次读文件都要重放历史上所有的 VersionDiff 来重建段链表,随着写入次数增加,读取延迟会线性爆炸。因此必须维护一个「当前视图快照」,直接记录该文件当前生效的完整段链表。 + +### cur 快照内容 + +- segments:当前文件视图包含的所有物理段,按逻辑偏移排序。 +- total_size:预计算的文件总长度,避免每次 stat 都遍历求和。 +- last_version_id:最后一次更新该快照的版本号。 + +### 更新逻辑 + +Append 场景:原快照段链表直接追加新段,覆盖写入。 +RandomWrite 场景:定位被覆盖的逻辑区间,拆分或替换对应段,生成新链表覆盖写入。 +Truncate 场景:遍历原链表裁剪长度,生成短链表覆盖写入。 + +### 内存缓存 + +热点文件的 cur 快照常驻内存 LRU 缓存,避免频繁读取持久化存储。TxEnd 后同步更新缓存。 + +--- + +### 块增长与聚合版本 + +### 问题 + +如果只有一个 data.raw,随着持续追加,文件会无限增长,即使后期 GC 回收空洞,稀疏文件的逻辑大小仍然巨大,管理不便。 + +### 方案 + +将物理存储拆分为多个固定大小的 Block 文件: +- block_0.raw、block_1.raw、block_2.raw ... +- 每个 Block 上限 1GB(可配置)。 +- 当前 Block 写满后,自动创建下一个 Block,后续 Append 写入新 Block。 + +### Block 内的 Diff 管理 + +每个 Block 内部仍然遵循追加写原则: +- 同一块内的多次写入,生成多个 Segment,全部追加在该 Block 尾部。 +- 每个 Block 维护自己的段链表和 cur 快照。 +- 一个虚拟文件的视图可以跨越多个 Block,由全局段链表串联各 Block 中的物理段。 + +### 聚合版本 + +当一个 Block 内的 diff 次数过多(例如一个 1GB 的 Block 积累了上万个 Segment),段链表变得很长,读取时需要遍历大量小段,性能下降。 + +聚合版本机制: +- 后台定期扫描高频修改的 Block。 +- 将该 Block 内某个虚拟文件的所有小段合并为少量大段,重写物理布局(复制到该 Block 的新尾部,生成合并后的 Segment)。 +- 更新 cur 快照引用新的合并段,旧小段标记为可回收。 +- 聚合后读取该文件只需访问少数几个大段,减少 IO 次数。 + +### 跨 Block 读取 + +读取虚拟文件时,按全局段链表定位每个 Segment 所属的 Block,依次从对应 Block 读取字节,拼接成完整视图。 + +--- + +## 第三阶段:SQLite 真实写入记录 + +**目标**:让 SQLite 在 /vfs 上正常运行,底层能精确记录每一次事务产生的字节段 Diff,证明 COW 机制对真实数据库工作负载有效。 + +### 验证目标 + +证明 SQLite 能无感知地运行在我们的 VFS 上,且我们能在底层精确追踪到每一次事务产生的字节段变更。 + +### 验证步骤 + +1. 启动 FUSE Daemon,挂载 /vfs。 +2. 让 SQLite 打开 /vfs/test.db 数据库文件。 +3. 执行 CREATE TABLE 和 INSERT 操作。 +4. 观察底层产生的 VersionDiff: + - 应该能看到 SQLite 写入的页大小(通常是 4KB)对应的 Segment。 + - 随机覆盖写场景:UPDATE 修改某行时,SQLite 会覆盖数据库文件的特定页,底层应产生 RandomWrite 类型的 Diff。 +5. 执行 SELECT,验证读取时能正确拼接段链表,SQLite 能正常读出数据。 +6. 执行 BEGIN ... COMMIT,验证事务边界是否能正确圈定一组 Diff。 + +### 验收标准 + +- SQLite 能完成建表、插入、查询、更新,无报错。 +- 底层能打印出每一次写入对应的 Segment(物理偏移、长度、操作类型)。 +- 随机写后,旧页的数据段仍保留在物理存储中,但不在当前视图链表里。 +- Truncate / Vacuum 操作能正确缩小文件视图。 + +--- + +## 第四阶段:KV 持久化元数据 + +**目标**:把内存中的路径到段链表的映射,持久化到 KV 存储中。KV key 与 VFS 虚拟路径百分之百对应,元数据 value 中记录保存策略、实际位置与预期位置。 + +### KV key 与 VFS 路径一一对应 + +- KV 存储中的 key 直接使用 VFS 虚拟路径,不做任何编码转换或哈希映射。 +- 例如 VFS 路径 /vfs/config/app.cfg,对应 KV key 就是 /vfs/config/app.cfg。 +- 这种一一对应关系保证:给定任意 VFS 路径,能直接定位到其元数据;给定任意 KV key,也知道它代表哪个虚拟文件。 +- 目录层级天然反映为 KV 的 key 层级,前缀扫描即可列出目录内容。 + +### 三层 key 逻辑 + +当前视图链 +- key 为虚拟路径本身,value 为 cur 快照(段链表 + total_size + last_version_id)。 +- 读取文件时直接查此 key,无需遍历历史日志。 + +历史快照 +- key 格式为 {path}@{version_id},value 为对应版本的完整段链表。 +- 仅当保存策略为保留历史时才写入。 + +全局段引用索引 +- key 格式为 ref:{block_id}:{offset},value 为 (path, version_id)。 +- GC 扫描时据此判断哪些物理段仍在被引用。 + +### 元数据 value 中的保存信息 + +每个虚拟路径的元数据 value 中必须包含以下字段: + +保存策略 +- strategy:local / region / global,决定该文件的 Diff 是否走 Raft 同步。 +- region_nodes:当 strategy 为 region 时,列出预期同步的目标节点列表。 + +预期保存位置 +- expected_nodes:根据策略计算出的应该保存该文件元数据的节点集合。 +- global 策略下为全集群节点;region 策略下为指定子集;local 策略下仅为本机。 + +实际保存位置 +- actual_nodes:记录当前哪些节点已经确认持有该文件的最新元数据。 +- 写入时本地节点首先更新,Raft 同步完成后逐步更新其他节点的确认状态。 +- 用于判断某节点是否滞后、是否需要触发补同步。 + +元数据与数据的分流原则 +- 元数据(KV 内部)100% 全部走 Raft 强一致同步,所有节点持有的元数据视图必须一致。 +- 真实数据(VFS 上的 value 字节)是条件性的:按保存策略决定是否需要跨节点同步。 +- local 策略的数据只写本机物理存储;region/global 策略的数据通过 QUIC 异步传输到其他节点。 + +### 内存优先到持久化的演进 + +内存优先 +- 第一阶段元数据全部放内存:虚拟路径到 cur 快照的映射表、历史 VersionDiff 列表、全局段引用索引。 + +简化持久化(可选) +- 如需进程重启后恢复,可将内存表序列化为单个 JSON / MessagePack 文件落盘: + - meta.json:记录所有虚拟文件的 cur 快照段链表和保存策略。 + - 启动时加载,运行时全内存操作,定期快照落盘。 + +排除持久 KV 引擎的原因 +- 第一阶段不需要前缀策略的持久化,策略树纯内存维护即可。 +- 不需要列族、WAL、复杂压缩,简单文件序列化足够。 +- 避免引入外部引擎带来的编译依赖和调优成本。 + +--- + +## 第五阶段:KV 事务模式 + +**目标**:在 KV 持久化之上实现显式事务边界。业务层或管理员可手动调用 TxnBegin / TxnEnd / TxnRollback,将多次 VFS 写入聚合为单个 COW 版本,而不是每次 write 都产生独立 Diff。 + +### 核心设计 + +事务会话 +- TxnBegin(path):为指定虚拟路径创建一个事务会话,分配唯一 txn_id。 +- 会话期间,所有对该路径的 write / truncate 操作不立即 Append 到物理存储,也不更新 cur 快照。 +- 变更内容暂存在内存缓冲区,记录待追加的 Segment 列表和待裁剪的 truncate 尺寸。 + +写入聚合 +- 事务内允许多次写入,缓冲区累积所有变更。 +- 例如 SQLite 一个事务内可能写入十几个页,全部缓冲在内存。 +- 随机覆盖写场景下,缓冲区可合并重叠区间的写入,只保留最终生效的字节,减少冗余物理段。 + +TxnEnd +- 将缓冲区中所有变更一次性 Append 到 data.raw 尾部。 +- 生成单条 VersionDiff,包含本次事务的全部新增 Segment 和最终文件尺寸。 +- 原子更新 cur 快照,对外可见。 +- 若策略允许,同时写入 hist 历史快照。 +- 事务内所有变更要么全部生效,要么全部不生效。 + +TxnRollback +- 直接丢弃内存缓冲区,无需回收物理空间(因为尚未真正 Append)。 +- 虚拟文件保持事务开始前的状态,无任何痕迹。 +- 轻量回滚,不涉及磁盘 IO。 + +手动调用触发 COW +- 管理员或业务进程可通过专用接口(如 UnixSocket、ioctl、xattr 扩展)主动调用 TxnBegin / TxnEnd。 +- 这种模式下,FUSE 层的 write 操作被挂起,直到显式 TxnEnd 才统一落盘。 +- 适用于需要精确控制版本边界的场景,例如数据库事务、批量配置文件更新。 + +与 FUSE 的集成 +- 默认模式下,每次 fsync 或文件 close 自动触发隐式 TxnEnd,兼容普通程序无感知使用。 +- 显式事务模式下,fsync 仅刷缓冲区到稳定存储,不生成新版本,直到 TxnEnd 才提交。 +- 两种模式可共存,单文件级别通过 xattr 或 open flag 切换。 + +--- + +## 第六阶段:KV Prefix 策略层 + +**目标**:在 KV 存储之上实现前缀策略树,支持按路径前缀统一配置同步策略、MVCC 开关、共享字段等。路径匹配采用最长前缀优先。 + +### 核心设计 + +前缀策略树 +- 内存中维护 Trie / Radix Tree,按路径层级插入前缀规则。 +- 每个前缀节点可绑定:同步策略(local / region / global)、MVCC 开关(keep_version / discard_old)、区域节点列表、共享字段。 +- 查询时按虚拟路径做最长前缀匹配,返回该路径生效的策略集合。 + +KV 中的策略存储 +- 策略规则本身作为特殊 key 存入 KV:policy:{prefix} -> 策略配置值。 +- 启动时从 KV 加载所有策略规则,重建内存 Trie。 +- 运行时策略变更实时写回 KV,保证重启后恢复。 + +共享字段 +- 同一前缀下的多个文件可共享公共元数据字段,减少重复存储。 +- 例如 /vfs/config/ 下所有文件共享同一个版本保留策略,无需每个文件单独记录。 + +xattr 动态覆盖 +- 单个文件可通过 xattr 设置 user.vfs.sync 和 user.vfs.version 覆盖前缀默认策略。 +- 动态覆盖仅影响该文件,不改变前缀规则本身。 + +--- + +## 第七阶段:基于 KV 的 MVCC 恢复 + +**目标**:利用 KV 中保存的历史快照,实现对单个路径的任意版本回滚恢复,不是全系统恢复,而是精确到某个 path 的某个历史版本。 + +### 核心设计 + +历史快照存储 +- KV 中按路径 + 版本号存储历史视图:hist:{path}:{version_id} -> 该版本对应的完整段链表。 +- 仅当 MVCC 开关为 keep_version 时才生成历史快照,discard_old 不保留历史。 +- 每次 TxnEnd 产生新版本前,把旧版本的 cur 快照复制一份存入 hist key。 + +单路径版本恢复 +- 恢复粒度精确到单个虚拟路径,不影响其他文件。 +- 恢复操作:读取 hist:{path}:{target_version} 得到历史段链表,覆盖写入 cur:{path},该文件立即回到目标版本视图。 +- 恢复不产生新物理字节,只修改元数据引用,轻量瞬时完成。 + +恢复后的写入 +- 恢复后的文件继续接受新写入,新写入基于恢复后的版本生成新的 Diff,历史版本链继续延伸。 +- 恢复操作本身可视为一个特殊版本节点,支持再次回滚到恢复之前的状态。 + +与 GC 的协同 +- 历史快照持有旧段引用,GC 不会回收这些段。 +- 若管理员决定删除某路径的某个历史版本,只需删除对应的 hist key,该版本引用的段失去引用后由 GC 后续回收。 + +--- + +## 附录:内存段 Diff 与 Git 的本质区别 + +| 维度 | Git | 本方案 | +|------|-----|--------| +| 存储粒度 | 完整文件 blob,diff 是字节差值压缩包 | 物理文件只存原始字节,diff 只存新增偏移坐标 | +| CPU 开销 | 需要计算字节差异 | 写入时直接追加分配新 offset,无需差分计算 | +| 磁盘开销 | 每条提交生成完整文件副本 | 多版本共享底层物理字节,仅新增少量偏移元数据 | +| 在线支持 | 无在线 fd、租约、truncate 原生支持 | 完整覆盖追加、随机覆盖、截断三类标准文件操作 | +| 块管理 | 无固定块大小概念 | 明确 1GB Block 边界,支持聚合版本优化 | + +--- + +## 第八阶段:etcd 协议兼容 + +**目标**:对外暴露 etcd v3 API 语义,上层工具(etcdctl、Kubernetes 等)可无缝接入。内部读取不走传统 KV 查询,而是直接从 VFS 文件流中获取数据。 + +### 协议兼容层 + +Key 到虚拟路径的映射 +- etcd 的 key 空间中以 /vfs/ 为前缀的 key,视为虚拟文件路径。 +- etcd Put 操作对应向 /vfs/{key} 写入文件内容。 +- etcd Get / Range 操作对应读取 /vfs/{key} 的文件内容,返回 value 为文件字节流。 + +写入路径 +- 客户端通过 etcd v3 API 发起 Put / Txn 请求。 +- 协议兼容层拦截 /vfs/ 前缀的 key,将 value 转化为文件写入操作。 +- 走 FUSE 拦截层 -> 内存段 Diff -> Append 到物理存储,产生 VersionDiff。 +- 元数据按策略写入 KV 持久层。 + +读取直接从 VFS 拿流 +- 传统 etcd 读取是查内存 B+ 树或 BoltDB,返回 value。 +- 本方案中,/vfs/ 前缀的 Get / Range 不走 KV 值查询,而是直接定位到该路径的 cur 快照。 +- 按 cur 快照中的段链表,从 Block 文件中顺序读取字节流,拼接后作为 value 返回给客户端。 +- 大文件读取天然支持流式返回,无需一次性加载完整 value 到内存。 + +事务语义 +- etcd Txn 的 compare 操作针对 /vfs/ 路径时,比较的是文件的版本号(last_version_id)或内容摘要。 +- etcd Txn 的 success / failure 操作中的 Put / Delete,映射为文件的写入 / 删除操作。 +- 事务边界与 TxnBegin / TxnEnd 对齐,保证原子性。 + +Watch 机制 +- 对 /vfs/ 路径建立 Watch 时,监听该路径的 cur 快照版本变化。 +- 任何写入导致 cur 快照更新,触发 Watch 事件推送。 +- 用于 runner 任务下发、配置热更新等场景。 + +--- + +## 第九阶段:QUIC mTLS Raft 算法 + +**目标**:实现基于 QUIC + mTLS 的 Raft 共识算法,替代 etcd 原生的 gRPC over TCP 传输层。节点间所有通信(Raft 日志、大文件字节、心跳探活)统一走 QUIC 连接,通过 mTLS 完成双向身份鉴权。 + +### 核心设计 + +QUIC 作为统一传输层 +- 节点间不建立传统 TCP 连接,全部使用 QUIC 长连接。 +- 一条 QUIC 连接内部通过不同 stream 隔离多类流量:Raft 日志流(低延迟、高优先级)、大文件字节同步流(高吞吐、可阻塞)、心跳探活流(轻量、定时)。 +- QUIC 内置连接迁移和 0-RTT 握手,节点 IP 变化时连接不中断。 + +mTLS 双向鉴权 +- 复用 etcd 的证书体系:CA 根证书、peer 证书、client 证书。 +- 每个节点启动时加载自己的 TLS 私钥和证书,加入集群前必须完成证书校验。 +- QUIC 握手阶段交换证书,握手失败则拒绝加入集群,防止非法节点接入。 + +Raft 日志传输 +- Leader 将 VersionDiff 序列化为 Raft 日志条目,通过 QUIC stream 发送给 Follower。 +- Follower 收到后写入本地 WAL,应用后再回复 ACK。 +- 相比原生 etcd 的 gRPC,QUIC stream 级流量控制避免头阻塞,Raft 日志不受大文件同步影响。 + +大文件字节同步 +- Raft 只同步偏移元数据,真实字节通过独立 QUIC stream 异步传输。 +- 节点收到 Raft MetaDiff 后,发现本地缺少某些 Segment,向持有节点发起 QUIC PULL 请求。 +- 支持断点续传:stream 中断后根据已确认 offset 恢复,不重新传输完整文件。 + +与 etcd 协议兼容的衔接 +- 对外仍暴露 etcd v3 API,客户端无感知。 +- 内部 Peer 通信全部替换为 QUIC mTLS,不依赖 etcd 原生的 gRPC 实现。 +- 复用 etcd 的 Raft 状态机逻辑(选举、日志复制、快照、成员变更),只替换网络传输层。 + +--- + +## 第十阶段:分布式租约与条件数据同步 + +**目标**:实现 FD 级别的分布式写租约,保证同一时刻只有一个节点能写入指定文件。同时实现条件同步机制:默认元数据全集群 Raft 同步,但实际的 VFS value 数据可按策略决定是否跨节点复制。 + +### 分布式租约 + +FD 打开即持有写租约,仅持有租约节点可写入,解决分布式文件并发写互斥。 + +核心逻辑 +- open 拿到 FD 后,当前节点向 Raft 申请该虚拟路径的写租约。 +- Lease 绑定 FD 生命周期:所有 FD 全部 release 关闭后,租约自动释放。 +- 集群全局单一租约,同一时刻仅一个节点持有。 +- 只有持有 Lease 的节点,FUSE 才允许执行 write / ftruncate;其他节点写入直接返回 EBUSY。 + +租约数据分层 +- 全局持久租约:存入持久 KV 并走 Raft 同步,所有节点可见谁持有锁,主键为虚拟路径。 +- 本机内存 FD 计数:仅当前节点内存维护,记录该节点打开了多少个 FD,不参与集群同步。 + +生命周期 +- open 时查询全局 Lease,无租约或已过期则向 Raft 提交抢占 Diff,Raft 多数派成功后本地引用计数加一。 +- write / ftruncate 前校验:若 holder_node 不是本机,直接拒绝。 +- release 时引用计数减一,归零后向 Raft 提交释放 Diff,清除全局 holder_node。 +- 超时兜底:每个 Lease 携带过期时间,持有节点定时心跳续租,宕机后心跳停止,其余节点后台扫描过期 Lease 自动释放。 + +只读优化 +- O_RDONLY 打开不抢占租约,任意节点可并行读,不阻塞。 +- O_WRONLY / O_RDWR 打开必须抢占写租约,互斥。 +- 同一节点多进程多 FD 打开同一文件:只占用一份全局租约,内存 FD 计数叠加,全部 close 才释放。 + +### 条件数据同步 + +默认行为:元数据全 Raft,value 本机保存 +- 元数据(KV 中的 cur 快照、hist 历史、ref 引用索引)100% 走 Raft 强一致同步,所有节点持有的元数据视图必须完全一致。 +- 默认情况下,实际的 VFS value 数据(Block 文件中的原始字节)只保存在写入节点的本地物理存储上,不主动复制到其他节点。 +- 其他节点通过 Raft 同步的元数据知道该文件存在、知道段链表偏移,但本地 Block 文件中可能没有对应的物理字节。 + +条件同步触发 +- region / global 策略下,写入节点在 TxnEnd 后主动将本次新增的 Segment 通过 QUIC stream 推送到目标节点。 +- 目标节点接收后 Append 到本地 Block 文件,更新本地 actual_nodes 记录。 +- 推送完成后,目标节点可以本地读取该文件,无需跨节点实时拉取。 + +默认读取行为:本地无数据则失败 +- 如果某节点收到元数据但本地 Block 文件中缺少对应物理字节,默认情况下读取直接返回失败(如返回 EIO 或专用错误码)。 +- 不自动触发网络拉取,避免跨节点读取带来的高延迟,确保本地读性能可预测。 +- 该节点只能等待条件同步或管理员手动触发数据迁移后,才能本地读取。 + +远程读取开关 +- KV 元数据 value 中增加 remote_read 布尔字段,默认 false。 +- 当 remote_read 显式置为 true 时,该文件允许在本地无数据的情况下,通过 QUIC 向持有节点实时拉取 Segment 并返回给读取方。 +- 拉取过程中可边收边返回,实现流式远程读取,不等待完整文件落地。 + +公网与内网场景区分 +- 公网场景:remote_read 保持默认 false。跨公网延迟高、带宽不稳定,不允许实时远程读取,必须通过条件同步预先把数据复制到目标节点后才能读。 +- 内网场景:管理员可将 remote_read 置为 true。内网带宽充足、延迟低,允许实时远程读取,简化数据分布管理,减少冗余副本。 +- 该开关按文件粒度配置,不同路径可独立选择策略。 + +本地策略短路 +- local 策略的文件完全不触发任何跨节点数据同步,元数据也不走 Raft,仅本机保存。 +- 本地文件不占用分布式租约,本机无锁自由读写。 + +同步状态记录 +- 每个虚拟路径的元数据 value 中包含 actual_nodes 字段,记录哪些节点已确认持有该文件的完整物理数据。 +- 集群管理员或自动化工具可查询同步状态,判断是否需要触发补同步或调整策略。 + +--- + +## 后续分布式扩展蓝图 + +单节点验证通过后,系统按以下蓝图扩展为完整分布式版本化 VFS 底座。 + +### 1. 五层分层架构 + +第一层 用户应用层 +- 上层程序、bash、dd、tail、SQLite 直接操作 /vfs/ FUSE 挂载目录,无侵入,标准 Linux 文件 API。 +- 业务主动调用 TxnBegin / TxnEnd 划定版本单元,一次数据库事务对应一个文件版本。 + +第二层 内核 VFS + FUSE Daemon 拦截层 +- 前缀策略路由:内存 Trie 前缀匹配树,长路径优先覆盖。每条路径绑定同步策略(local / region / global)和 MVCC 版本策略(keep_version / discard_old)。 +- COW 自定义 MVCC:底层只允许 Append,写入全部 COW 在尾部新增字节段。版本不复制完整文件,仅新增变更片段,读取时拼接 offset 链表还原视图。 +- 事务与同步调度:对外暴露 TxnBegin / TxnEnd / TxnRollback。Begin 创建内存缓冲区缓存偏移,End 打包为单条元数据 Diff 按策略分流。 + +第三层 魔改 etcd Raft 共识层 +- 复用 etcd 成熟 Raft 集群、mTLS 鉴权、事件广播基建。 +- Raft 仅传输极小 offset 元数据 Diff,不传输完整文件。带宽开销极低。 +- 全局单调版本号由 Raft 日志序列号提供,天然解决多节点并发写入冲突。 + +第四层 元数据存储层 +- 放弃列族分流方案,采用自研前缀策略树管理。 +- 持久 KV 只分三层 Key 逻辑:当前视图链 cur、历史快照 hist、全局段引用 ref。 +- 真实业务字节不存入此层,仅存储虚拟文件元索引。 + +第五层 底层物理存储层 +- 单块或多块全局预分配稀疏大文件,唯一写入方式是 Append 追加。 +- 后台常驻 GC 协程扫描废弃 offset 空洞,裁剪稀疏文件释放磁盘。 +- 长期冷版本分片异步归档至次级存储,主文件仅保留活跃数据段。 + +### 2. 三大核心差异化设计 + +自定义字节段 MVCC +- 不同于 MinIO 对象 MVCC(每次更新复制完整文件),本架构仅新增变更字节段,复用全部未修改内容。 +- 不同于 RocksDB 原生 KV MVCC(整条 value 多版本),本架构粒度下放到文件内部字节偏移,版本逻辑由 FUSE 业务层控制,可按路径开关版本留存。 + +三级分层同步策略 +- local:仅本机,不走 Raft,低延迟,适合临时日志和单机 SQLite。 +- region:选择性副本同步,适合机房批量运维日志,仅同步指定节点。 +- global:全集群 Raft 强一致,适合网关配置、集群拓扑,完整历史版本可回滚。 + +兼容实时流式 IO +- 原生 etcd 仅支持整块 Put,无 fd、lseek、tail 增量读取。 +- 本架构底层是追加式大文件,天然支持长文本流式分段写入、实时日志滚动读取。 + +### 3. Raft 共识层与 etcd 协议兼容 + +复用 etcd 成熟 Raft 集群基建,彻底替换原生 BoltDB 存储层,同时保持与 etcd 协议的兼容性。 + +etcd 协议兼容 +- 对外暴露 etcd v3 API 语义,上层工具(etcdctl、Kubernetes 等)可无缝接入,将 /vfs/ 前缀下的 key 视为虚拟文件路径。 +- 内部节点间 Peer 通信不采用 etcd 原生 gRPC over TCP,而是统一使用 QUIC + mTLS 传输。 +- 复用 etcd 的证书体系(CA、peer cert、client cert)做双向身份鉴权,节点加入集群前必须完成证书校验。 +- QUIC 连接同时承载两类流量:Raft 元数据日志通道(轻量、低延迟)和大文件字节同步通道(流式、高吞吐),通过不同 stream 隔离。 + +元数据 Diff 同步 +- Raft 日志内容只包含 VersionDiff 级别的偏移元数据:路径、新增物理段区间、操作类型、截断尺寸、版本策略。 +- 绝不传输任何文件二进制内容,大块字节走独立 QUIC 异步 stream,与 Raft 元通道完全解耦。 +- 全局单调版本号直接复用 Raft 日志索引 Index,天然时序唯一。 + +分流规则 +- local 策略:完全跳过 Raft,仅写入本机元存储。 +- region / global 策略:Diff 提交 Raft 多数派,全集群同步版本索引。 + +集群事件广播 +- 复用 etcd watch 机制,节点监听 /vfs/runner/task/ 等路径变更,触发本地批量 bash 执行。 + +### 5. QUIC 大文件异步流式同步 + +底层大文件分片走独立 QUIC 连接,与 Raft 元通道完全解耦。 + +连接管理 +- 节点间基于 etcd 节点发现维护 QUIC 长连接池,复用 etcd mTLS 证书。 + +分片策略 +- 每个 Block 文件按固定大小逻辑分片,每片有独立 checksum。 +- 断点续传:stream 中断后根据已确认 offset 恢复传输。 + +拉取与推送 +- 拉取触发:节点收到 Raft MetaDiff 后,发现本地缺少某些 ChunkRef,向 leader 或指定节点发起 PULL 请求。 +- 推送触发:region 策略下,节点主动向目标节点推送本地新 Append 的字节段。 + +流式写入 +- QUIC 接收端直接 Append 到本地 Block 文件,不缓冲完整文件,边收边落盘,内存友好。 +- 传输失败不影响 Raft 共识,仅影响数据可用性,节点可稍后重拉。 + +### 6. 三级分层同步策略 + +local 本地缓存通路 +- 临时日志、单机 SQLite,无集群同步,低延迟。 +- 完全跳过 Raft,直接写本地 Block 文件和元存储。 +- 本地路径不走分布式租约,本机无锁自由读写。 + +global 全局强一致通路 +- 网关配置、集群拓扑、任务定义,全节点 Raft 同步,完整历史版本可回滚。 +- Diff 打包为 Raft Propose,走 etcd Raft 多数派确认。 +- 异步提交:写入本地存储成功后即返回上层,Raft 同步在后台完成。 + +region 区域选择性副本通路 +- 机房批量运维日志,仅同步到 RegionNodes 指定的节点子集。 +- 走轻量级 gossip 或自定义 topic,不经过完整 Raft 多数派。 +- 适合大带宽、低一致性要求的日志汇聚场景。 + +### 7. 内置批量 bash 执行引擎 + +去中心化 Ansible ad-hoc,无中控单点。 + +下发逻辑 +- CLI 或内网接口将 bash 任务写入 /vfs/runner/task/ 全局同步路径。 +- 任务内容包含 ID、节点标签选择器、脚本、超时、环境变量。 + +执行逻辑 +- 全网节点通过 FUSE 监听路径变更,匹配 NodeSelector 与本地标签后,本地 fork 子进程执行。 +- 纯一次性执行,无持续 reconcile 收敛逻辑,失败不重试。 + +日志落盘 +- 脚本输出直接 COW 写入本机 VFS Block 文件,offset 元数据按策略同步集群。 +- stdout / stderr 流式落盘,实时可读。 + +资源隔离 +- 通过 cgroup 或 setrlimit 限制任务 CPU、内存、执行时间,防止单任务耗尽节点资源。 + +### 8. SQLite 与事务边界 + +SQLite 不是独立的分布式模块,而是 VFS 的标准消费者之一,与 bash、普通程序使用同一套文件 API。 + +事务映射 +- 业务层在 BEGIN 时调用 TxnBegin,创建事务缓冲区。 +- SQL 读写全部走标准文件 API,数据暂存事务缓冲,不生成版本。 +- COMMIT 时调用 TxnEnd,将缓冲中的变更一次性 Append 到物理存储,产生一条 VersionDiff。 +- ROLLBACK 时调用 TxnRollback,直接丢弃缓冲,无需回收物理空间。 + +后台操作过滤 +- Vacuum、Checkpoint 等 SQLite 后台无事务裸写,可配置过滤不生成正式版本,减少冗余版本。 + +分布式自然达成 +- SQLite 的分布式能力完全由底层 VFS 提供:Diff 走 Raft 同步,租约保证单写节点,SQLite 本身无感知。 +- 数据库文件可按业务需求配置为 local 策略(单机缓存)或 global 策略(集群同步)。 + +### 9. GC 与空洞回收 + +引用扫描 +- 后台协程遍历全部 cur 当前视图链表和 hist 历史快照,收集所有被引用的物理区间。 +- 对比 Block 文件全部已占用区间,找出无任何引用的空白段。 + +物理裁剪 +- 对稀疏文件执行 fallocate punch hole 释放空洞对应的物理块(Linux)。 +- macOS 不支持 punch hole 时,采用复制存活数据到新文件并原子替换的策略。 + +触发策略 +- 定时(如每小时)、定量(空洞比例超过阈值)、或手动触发。 +- discard_old 策略的临时文件,旧段无快照引用,会快速被回收。 +- keep_version 策略的配置文件,历史快照持续引用旧段,永久保留。 + +并发安全 +- GC 期间允许新 Append(新数据在文件尾部,不影响已扫描区间),天然安全。 +- 禁止原地修改的底层原则保证 GC 不会误删正在写入的数据。 + +### 10. 冷数据归档 + +归档标准 +- 版本最后访问时间超过 N 天,或版本号远小于当前版本,判定为冷数据。 + +归档流程 +- 扫描冷版本,将其引用的 ChunkRef 对应字节段复制到归档存储(本地二级目录、S3、MinIO、NFS 等插件化目标)。 +- 更新元数据记录归档位置,主文件对应区间 punch hole 变为可 GC 空洞。 + +按需回温 +- 读取归档版本时,从次级存储拉取字节段回主文件或缓存区,重建可读视图。 + +与 GC 协同 +- 归档完成后,主文件对应区间失去引用,变为空洞,由 GC 后续回收物理空间。 + +--- + +## 十、修订记录 + +| 日期 | 版本 | 说明 | +|------|------|------| +| 2026-07-06 | v0.1 | 初始架构拆解,17 个 Phase,涵盖五层架构 | +| 2026-07-06 | v0.2 | 聚焦单节点验证,删除所有代码块,删除 RocksDB 依赖,新增块增长与聚合版本设计 | +| 2026-07-06 | v0.3 | 重构为七阶段递进结构:FUSE -> Diff -> SQLite -> KV -> Prefix -> MVCC -> etcd 协议兼容,写入场景扩展为五种 | +| 2026-07-06 | v0.4 | 增加第五阶段 KV 事务模式(TxnBegin / TxnEnd / TxnRollback),支持手动调用聚合多次 VFS 写入为单事务 | +| 2026-07-06 | v0.5 | 重构为十阶段递进结构,新增第九阶段 QUIC mTLS Raft、第十阶段分布式租约与条件数据同步;KV key 与 VFS 路径一一对应;元数据 value 包含保存策略、预期位置、实际位置 | +| 2026-07-06 | v0.6 | 第十阶段读取行为调整:默认本地无数据则读取失败,不自动网络拉取;增加 remote_read 开关区分公网/内网场景 | diff --git a/src/fuse_ops.cpp b/src/fuse_ops.cpp new file mode 100644 index 0000000..5ab00c4 --- /dev/null +++ b/src/fuse_ops.cpp @@ -0,0 +1,302 @@ +#include "fuse_ops.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logger.h" + +namespace etvd { + +namespace { + +// Absolute backing directory. Set once in main() before fuse_main(); only read +// afterwards, so concurrent reads from FUSE worker threads are safe. +std::string g_root; + +// Join the backing root with a FUSE virtual path (always starts with '/'). +std::string Real(const char* path) { return g_root + path; } + +inline int Err() { return -errno; } + +std::string Hex(unsigned long long v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "0x%llx", v); + return std::string(buf); +} + +std::string Rc(long long rc) { return "rc=" + std::to_string(rc); } + +std::string OffSize(off_t off, size_t size) { + return "off=" + std::to_string(static_cast(off)) + + " size=" + std::to_string(static_cast(size)); +} + +void* etvd_init(struct fuse_conn_info* conn, struct fuse_config* cfg) { + (void)conn; + (void)cfg; + LogOp("init", "/"); + return nullptr; +} + +void etvd_destroy(void* private_data) { + (void)private_data; + LogOp("destroy", "/"); +} + +int etvd_getattr(const char* path, struct stat* st, struct fuse_file_info* fi) { + (void)fi; + int rc = ::stat(Real(path).c_str(), st) == 0 ? 0 : Err(); + LogOp("getattr", path, Rc(rc)); + return rc; +} + +int etvd_readlink(const char* path, char* buf, size_t size) { + int rc; + if (size == 0) { + rc = -EINVAL; + } else { + ssize_t n = ::readlink(Real(path).c_str(), buf, size - 1); + if (n < 0) { + rc = Err(); + } else { + buf[n] = '\0'; + rc = 0; + } + } + LogOp("readlink", path, Rc(rc)); + return rc; +} + +int etvd_mknod(const char* path, mode_t mode, dev_t rdev) { + int rc = ::mknod(Real(path).c_str(), mode, rdev) == 0 ? 0 : Err(); + LogOp("mknod", path, Rc(rc)); + return rc; +} + +int etvd_mkdir(const char* path, mode_t mode) { + int rc = ::mkdir(Real(path).c_str(), mode) == 0 ? 0 : Err(); + LogOp("mkdir", path, Rc(rc)); + return rc; +} + +int etvd_unlink(const char* path) { + int rc = ::unlink(Real(path).c_str()) == 0 ? 0 : Err(); + LogOp("unlink", path, Rc(rc)); + return rc; +} + +int etvd_rmdir(const char* path) { + int rc = ::rmdir(Real(path).c_str()) == 0 ? 0 : Err(); + LogOp("rmdir", path, Rc(rc)); + return rc; +} + +int etvd_symlink(const char* from, const char* to) { + int rc = ::symlink(from, Real(to).c_str()) == 0 ? 0 : Err(); + LogOp("symlink", to, std::string("->") + from + " " + Rc(rc)); + return rc; +} + +int etvd_rename(const char* from, const char* to, unsigned int flags) { + int rc = ::renameat2(AT_FDCWD, Real(from).c_str(), AT_FDCWD, + Real(to).c_str(), flags) == 0 + ? 0 + : Err(); + LogOp("rename", from, std::string("->") + to + " " + Rc(rc)); + return rc; +} + +int etvd_chmod(const char* path, mode_t mode, struct fuse_file_info* fi) { + (void)fi; + int rc = ::chmod(Real(path).c_str(), mode) == 0 ? 0 : Err(); + LogOp("chmod", path, Rc(rc)); + return rc; +} + +int etvd_chown(const char* path, uid_t uid, gid_t gid, + struct fuse_file_info* fi) { + (void)fi; + int rc = ::lchown(Real(path).c_str(), uid, gid) == 0 ? 0 : Err(); + LogOp("chown", path, Rc(rc)); + return rc; +} + +int etvd_truncate(const char* path, off_t size, struct fuse_file_info* fi) { + (void)fi; + int rc = ::truncate(Real(path).c_str(), size) == 0 ? 0 : Err(); + LogOp("truncate", path, + "size=" + std::to_string(static_cast(size)) + " " + Rc(rc)); + return rc; +} + +int etvd_open(const char* path, struct fuse_file_info* fi) { + int fd = ::open(Real(path).c_str(), fi->flags); + int rc = fd >= 0 ? 0 : Err(); + if (rc == 0) fi->fh = static_cast(fd); + LogOp("open", path, + "flags=" + Hex(static_cast(fi->flags)) + " " + Rc(rc)); + return rc; +} + +int etvd_create(const char* path, mode_t mode, struct fuse_file_info* fi) { + int fd = ::open(Real(path).c_str(), fi->flags, mode); + int rc = fd >= 0 ? 0 : Err(); + if (rc == 0) fi->fh = static_cast(fd); + char m[32]; + std::snprintf(m, sizeof(m), "mode=0%o", static_cast(mode)); + LogOp("create", path, + std::string(m) + " flags=" + + Hex(static_cast(fi->flags)) + " " + Rc(rc)); + return rc; +} + +int etvd_read(const char* path, char* buf, size_t size, off_t off, + struct fuse_file_info* fi) { + ssize_t rc = ::pread(static_cast(fi->fh), buf, size, off); + if (rc < 0) rc = Err(); + LogOp("read", path, OffSize(off, size) + " " + Rc(static_cast(rc))); + return static_cast(rc); +} + +int etvd_write(const char* path, const char* buf, size_t size, off_t off, + struct fuse_file_info* fi) { + ssize_t rc = ::pwrite(static_cast(fi->fh), buf, size, off); + if (rc < 0) rc = Err(); + size_t shown = rc > 0 ? static_cast(rc) : 0; + LogOp("write", path, + OffSize(off, size) + " " + Rc(static_cast(rc)) + + " data=" + HexPreview(buf, shown)); + return static_cast(rc); +} + +int etvd_statfs(const char* path, struct statvfs* sv) { + int rc = ::statvfs(Real(path).c_str(), sv) == 0 ? 0 : Err(); + LogOp("statfs", path, Rc(rc)); + return rc; +} + +int etvd_flush(const char* path, struct fuse_file_info* fi) { + // pwrite/pwrite already push data synchronously; flush is a no-op here. + (void)fi; + LogOp("flush", path, Rc(0)); + return 0; +} + +int etvd_release(const char* path, struct fuse_file_info* fi) { + int rc = ::close(static_cast(fi->fh)) == 0 ? 0 : Err(); + LogOp("release", path, Rc(rc)); + return rc; +} + +int etvd_fsync(const char* path, int datasync, struct fuse_file_info* fi) { + int fd = static_cast(fi->fh); + int rc = (datasync ? ::fdatasync(fd) : ::fsync(fd)) == 0 ? 0 : Err(); + LogOp("fsync", path, + std::string("datasync=") + (datasync ? "1" : "0") + " " + Rc(rc)); + return rc; +} + +int etvd_readdir(const char* path, void* buf, fuse_fill_dir_t filler, off_t off, + struct fuse_file_info* fi, enum fuse_readdir_flags flags) { + (void)off; + (void)fi; + (void)flags; + std::vector names; + int rc = 0; + DIR* d = ::opendir(Real(path).c_str()); + if (d == nullptr) { + rc = Err(); + } else { + struct dirent* de; + errno = 0; + while ((de = ::readdir(d)) != nullptr) { + if (std::strcmp(de->d_name, ".") == 0 || std::strcmp(de->d_name, "..") == 0) + continue; + names.emplace_back(de->d_name); + } + int saved = errno; // capture before closedir clobbers it + ::closedir(d); + if (saved != 0) rc = -saved; + } + if (rc == 0) { + const auto kFlags = static_cast(0); + filler(buf, ".", nullptr, 0, kFlags); + filler(buf, "..", nullptr, 0, kFlags); + for (const std::string& n : names) + filler(buf, n.c_str(), nullptr, 0, kFlags); + } + LogOp("readdir", path, Rc(rc) + " n=" + std::to_string(names.size())); + return rc; +} + +int etvd_utimens(const char* path, const struct timespec ts[2], + struct fuse_file_info* fi) { + (void)fi; + int rc = ::utimensat(AT_FDCWD, Real(path).c_str(), ts, AT_SYMLINK_NOFOLLOW) == 0 + ? 0 + : Err(); + LogOp("utimens", path, Rc(rc)); + return rc; +} + +int etvd_lock(const char* path, struct fuse_file_info* fi, int cmd, + struct flock* fl) { + (void)path; + (void)fi; + // Phase 1: advisory locking is not enforced. Grant every request and report + // no conflicts so fcntl-based engines (SQLite) run in single-process mode. + // Real mutual exclusion arrives with the distributed write lease (phase 10). + // Not logged -- high-frequency and noisy. + if (cmd == F_GETLK) fl->l_type = F_UNLCK; + return 0; +} + +} // namespace + +void SetRoot(std::string root) { g_root = std::move(root); } + +const struct fuse_operations* EtvdFuseOps() { + // Zero-initialized once (static storage); unassigned callbacks stay NULL, + // making FUSE return -ENOSYS for ops we don't implement (xattr, fallocate). + static struct fuse_operations ops; + ops.getattr = etvd_getattr; + ops.readlink = etvd_readlink; + ops.mknod = etvd_mknod; + ops.mkdir = etvd_mkdir; + ops.unlink = etvd_unlink; + ops.rmdir = etvd_rmdir; + ops.symlink = etvd_symlink; + ops.rename = etvd_rename; + ops.chmod = etvd_chmod; + ops.chown = etvd_chown; + ops.truncate = etvd_truncate; + ops.open = etvd_open; + ops.read = etvd_read; + ops.write = etvd_write; + ops.statfs = etvd_statfs; + ops.flush = etvd_flush; + ops.release = etvd_release; + ops.fsync = etvd_fsync; + ops.readdir = etvd_readdir; + ops.init = etvd_init; + ops.destroy = etvd_destroy; + ops.lock = etvd_lock; + ops.utimens = etvd_utimens; + ops.create = etvd_create; + return &ops; +} + +} // namespace etvd diff --git a/src/fuse_ops.h b/src/fuse_ops.h new file mode 100644 index 0000000..bc4900a --- /dev/null +++ b/src/fuse_ops.h @@ -0,0 +1,21 @@ +#pragma once + +#ifndef FUSE_USE_VERSION +#define FUSE_USE_VERSION 31 +#endif +#include + +#include + +namespace etvd { + +// Set the absolute backing directory the callbacks forward to. Must be called +// once before fuse_main(). libfuse3 chdir()'s to "/" before serving, so this +// must be an absolute path. +void SetRoot(std::string root); + +// The FUSE3 operations table: every callback logs the call, then performs the +// operation directly against the backing directory (passthrough). +const struct fuse_operations* EtvdFuseOps(); + +} // namespace etvd diff --git a/src/logger.h b/src/logger.h new file mode 100644 index 0000000..19a05f6 --- /dev/null +++ b/src/logger.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +namespace etvd { + +// Thread-safe sink for the operation log. Phase 1's "observe every write from +// the bottom" goal is met by routing every FUSE callback through here. Output +// goes to stderr so it never collides with a client program's own stdout. +class Logger { + public: + static Logger& Instance() { + static Logger inst; + return inst; + } + + // Write one logical line atomically; appends a newline if missing. + void WriteLine(const std::string& line) { + std::lock_guard lk(mu_); + if (!line.empty()) { + std::fwrite(line.data(), 1, line.size(), stderr); + if (line.back() != '\n') std::fputc('\n', stderr); + } else { + std::fputc('\n', stderr); + } + std::fflush(stderr); + } + + private: + Logger() = default; + std::mutex mu_; +}; + +// Up to `cap` bytes of `data` rendered as hex, for short write previews. +inline std::string HexPreview(const char* data, size_t len, size_t cap = 16) { + if (data == nullptr || len == 0) return std::string(); + static const char* kHex = "0123456789abcdef"; + const size_t show = len < cap ? len : cap; + std::string s; + s.reserve(show * 2 + 3); + for (size_t i = 0; i < show; ++i) { + unsigned char c = static_cast(data[i]); + s.push_back(kHex[c >> 4]); + s.push_back(kHex[c & 0x0f]); + } + if (len > cap) s += "..."; + return s; +} + +// Emit a structured log line: "[etvd] [detail]". +inline void LogOp(const char* op, const char* path, + const std::string& detail = "") { + std::string line; + line.reserve(64 + detail.size()); + line += "[etvd] "; + line += op; + for (size_t i = std::strlen(op); i < 10; ++i) line.push_back(' '); + line += (path != nullptr ? path : "(null)"); + if (!detail.empty()) { + line.push_back(' '); + line += detail; + } + Logger::Instance().WriteLine(line); +} + +} // namespace etvd diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..f46649c --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,124 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include "fuse_ops.h" // brings in with FUSE_USE_VERSION +#include "logger.h" + +namespace { + +struct Options { + const char* backend; +}; + +const struct fuse_opt kOptDefs[] = { + {"backend=%s", offsetof(struct Options, backend), 0}, + FUSE_OPT_END, +}; + +void Usage(const char* prog) { + std::fprintf(stderr, + "etvd - phase 1 FUSE interception daemon\n" + "\n" + "Usage: %s [fuse-options...] [-o backend=DIR]\n" + "\n" + "Mounts and forwards every file operation to a backing\n" + "directory (passthrough), logging each call to stderr.\n" + "\n" + "Options:\n" + " -o backend=DIR backing directory (default: ./backend)\n" + " -f foreground (forced on)\n" + " -d FUSE debug\n" + "\n" + "Example:\n" + " %s mnt -o backend=./backend\n" + " fusermount3 -u mnt # unmount\n", + prog, prog); +} + +bool EnsureDir(const std::string& dir) { + struct stat st; + if (::stat(dir.c_str(), &st) == 0) return S_ISDIR(st.st_mode); + if (::mkdir(dir.c_str(), 0755) == 0) return true; // parent must exist + return false; +} + +// Resolve `dir` to an absolute, canonical path in place. libfuse3 chdir()'s to +// "/" before serving requests, so any path the daemon resolves at request time +// (the backend root) must be absolute or it will be misread relative to "/". +// Requires `dir` to already exist (call EnsureDir first). +bool ToAbsolute(std::string& dir) { + char* abs = ::realpath(dir.c_str(), nullptr); + if (abs == nullptr) return false; + dir = abs; + std::free(abs); + return true; +} + +} // namespace + +int main(int argc, char* argv[]) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "-h") == 0 || + std::strcmp(argv[i], "--help") == 0) { + Usage(argv[0]); + return 0; + } + } + if (argc < 2 || argv[1][0] == '-') { + Usage(argv[0]); + return 1; + } + std::string mountpoint = argv[1]; + + // Create + absolutize the mountpoint, then feed the absolute path back into + // argv so fuse_main mounts there regardless of any later chdir. + if (!EnsureDir(mountpoint)) { + std::fprintf(stderr, "etvd: mountpoint '%s' unusable\n", mountpoint.c_str()); + return 1; + } + if (!ToAbsolute(mountpoint)) { + std::fprintf(stderr, "etvd: cannot resolve mountpoint '%s'\n", + mountpoint.c_str()); + return 1; + } + argv[1] = ::strdup(mountpoint.c_str()); + + struct fuse_args args = FUSE_ARGS_INIT(argc, argv); + Options opts = {}; + if (fuse_opt_parse(&args, &opts, kOptDefs, nullptr) != 0) { + std::fprintf(stderr, "etvd: failed to parse options\n"); + return 1; + } + std::string backend_dir = opts.backend ? opts.backend : "./backend"; + + if (!EnsureDir(backend_dir)) { + std::fprintf(stderr, "etvd: backend dir '%s' unusable\n", + backend_dir.c_str()); + return 1; + } + if (!ToAbsolute(backend_dir)) { + std::fprintf(stderr, "etvd: cannot resolve backend dir '%s'\n", + backend_dir.c_str()); + return 1; + } + + // Force foreground (logs stream, Ctrl-C unmounts) and let the kernel enforce + // access checks against the modes reported from the backing files. + fuse_opt_add_arg(&args, "-f"); + fuse_opt_add_arg(&args, "-o"); + fuse_opt_add_arg(&args, "default_permissions"); + + etvd::SetRoot(backend_dir); + etvd::LogOp("start", "/", + "backend=" + backend_dir + " mount=" + mountpoint); + + const struct fuse_operations* ops = etvd::EtvdFuseOps(); + int rc = fuse_main(args.argc, args.argv, ops, nullptr); + return rc; +}