Coverage for volexport/api_volume.py: 82%
124 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-28 12:48 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-28 12:48 +0000
1import datetime
2from typing import Annotated
3from enum import Enum
4from fastapi import APIRouter, HTTPException
5from pydantic import BaseModel, Field, AfterValidator
6from .config2 import config2
7from .config import config
8from .lvm2 import LV, VG
9from .tgtd import Tgtd
11router = APIRouter()
14def _is_volsize(value: int):
15 if value % 512 != 0:
16 raise ValueError(f"invalid volume size: {value} is not multiple of 512")
17 return value
20VolumeSize = Annotated[int, AfterValidator(_is_volsize)]
23class VolumeCreateRequest(BaseModel):
24 """Request type for POST /volume"""
26 name: str = Field(description="Name of the volume to create", examples=["volume1"])
27 size: VolumeSize = Field(description="Size of the volume in bytes", examples=[1073741824], gt=0)
30class VolumeCreateResponse(BaseModel):
31 """Response type for POST /volume"""
33 name: str = Field(description="Name of the created volume", examples=["volume1"])
34 size: VolumeSize = Field(description="Size of the created volume in bytes", examples=[1073741824], gt=0)
37class VolumeReadResponse(BaseModel):
38 """Response type for GET /volume/{name}, GET /volume, POST /volume/{name}"""
40 name: str = Field(description="Name of the volume", examples=["volume1"])
41 created: datetime.datetime = Field(description="Creation timestamp of the volume", examples=["2023-10-01T12:00:00"])
42 size: VolumeSize = Field(description="Size of the volume in bytes", examples=[1073741824], gt=0)
43 used: bool = Field(description="opened or not", examples=[True, False])
44 readonly: bool = Field(description="true if read-only", examples=[True, False])
45 thin: bool = Field(description="true if thin volume", examples=[True, False])
46 parent: str | None = Field(description="parent volname if snapshot")
49class VolumeUpdateRequest(BaseModel):
50 """Request type for POST /volume/{name}"""
52 size: VolumeSize | None = Field(
53 default=None, description="New size of the volume in bytes", examples=[2147483648], gt=0
54 )
55 readonly: bool | None = Field(default=None, description="Set volume to read-only if true", examples=[True, False])
58class Filesystem(str, Enum):
59 """supported filesystems"""
61 ext4 = "ext4"
62 xfs = "xfs"
63 btrfs = "btrfs"
64 vfat = "vfat"
65 ntfs = "ntfs"
66 exfat = "exfat"
67 nilfs2 = "nilfs2"
70class VolumeFormatRequest(BaseModel):
71 """Request type for POST /volume/{name}/mkfs"""
73 filesystem: Filesystem = Field(default=Filesystem.ext4, description="Make filesystem in the volume")
74 label: str | None = Field(default=None, description="Label of filesystem")
77class SnapshotCreateRequest(BaseModel):
78 """Request type for POST /volume/{name}/snapshot"""
80 name: str = Field(description="Name of snapshot volume", examples=["snap001", "snap002"])
81 size: int | None = Field(default=None, description="Size of snapshot CoW (ignore if using thinpool)")
84class PoolStats(BaseModel):
85 """Response type for GET /stats/volume"""
87 total: int = Field(description="Total size of the pool in bytes", examples=[10737418240])
88 used: int = Field(description="Used size of the pool in bytes", examples=[5368709120])
89 free: int = Field(description="Free size of the pool in bytes", examples=[5368709120])
90 snapshots: int = Field(description="Number of snapshots in the pool", examples=[5])
91 volumes: int = Field(description="Number of volumes in the pool", examples=[10])
94@router.get("/volume", description="List all volumes")
95def list_volume() -> list[VolumeReadResponse]:
96 return [VolumeReadResponse.model_validate(x) for x in LV(config2.VG).volume_list()]
99@router.post("/volume", description="Create a new volume")
100def create_volume(arg: VolumeCreateRequest) -> VolumeCreateResponse:
101 if config.LVM_THINPOOL: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 return VolumeCreateResponse.model_validate(
103 LV(config2.VG, arg.name).create_thin(size=arg.size, thinpool=config.LVM_THINPOOL)
104 )
105 return VolumeCreateResponse.model_validate(LV(config2.VG, arg.name).create(size=arg.size))
108@router.get("/volume/{name}", description="Read volume details by name")
109def read_volume(name) -> VolumeReadResponse:
110 res = LV(config2.VG, name).volume_read()
111 if res is None:
112 raise HTTPException(status_code=404, detail="volume not found")
113 return VolumeReadResponse.model_validate(res)
116@router.delete("/volume/{name}", description="Delete a volume by name")
117def delete_volume(name) -> dict:
118 LV(config2.VG, name).delete()
119 return {}
122@router.post("/volume/{name}/snapshot", description="Create snapshot")
123def create_snapshot(name, arg: SnapshotCreateRequest) -> VolumeReadResponse:
124 if config.LVM_THINPOOL: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 res = LV(config2.VG, arg.name).create_thinsnap(parent=name)
126 return VolumeReadResponse.model_validate(res)
127 assert arg.size
128 res = LV(config2.VG, arg.name).create_snapshot(size=arg.size, parent=name)
129 return VolumeReadResponse.model_validate(res)
132@router.get("/volume/{name}/snapshot", description="List snapshot")
133def list_snapshot(name) -> list[VolumeReadResponse]:
134 return [VolumeReadResponse.model_validate(x) for x in LV(config2.VG).volume_list() if x.get("parent") == name]
137@router.get("/volume/{name}/snapshot/{snapname}", description="Read snapshot")
138def read_snapshot(name, snapname) -> VolumeReadResponse:
139 # check if name is parent
140 lv = LV(config2.VG, snapname)
141 if lv.get_parent() != name:
142 raise HTTPException(status_code=404, detail="volume not found")
143 res = LV(config2.VG, snapname).volume_read()
144 if res is None:
145 raise HTTPException(status_code=404, detail="volume not found")
146 return VolumeReadResponse.model_validate(res)
149@router.delete("/volume/{name}/snapshot/{snapname}", description="Delete snapshot")
150def delete_snapshot(name, snapname) -> dict:
151 # check if name is parent
152 lv = LV(config2.VG, snapname)
153 if lv.get_parent() != name:
154 raise HTTPException(status_code=404, detail="volume not found")
155 lv.delete()
156 return {}
159@router.post("/volume/{name}", description="Update a volume by name")
160def update_volume(name, arg: VolumeUpdateRequest) -> VolumeReadResponse:
161 lv = LV(config2.VG, name)
162 if arg.readonly is not None:
163 lv.read_only(arg.readonly)
164 if arg.size is not None:
165 lv.resize(arg.size)
166 try:
167 Tgtd().refresh_volume_bypath(lv.volume_vol2path())
168 except FileNotFoundError:
169 # not exported
170 pass
171 return VolumeReadResponse.model_validate(lv.volume_read())
174@router.post("/volume/{name}/mkfs", description="Format a volume, make filesystem")
175def format_volume(name, arg: VolumeFormatRequest) -> VolumeReadResponse:
176 lv = LV(config2.VG, name)
177 lv.format_volume(arg.filesystem.value, arg.label)
178 return VolumeReadResponse.model_validate(lv.volume_read())
181@router.get("/stats/volume", description="Get statistics of the volume pool")
182def stats_volume() -> PoolStats:
183 info = VG(config2.VG).get()
184 if info is None:
185 raise HTTPException(status_code=404, detail="pool not found")
186 vols = int(info["lv_count"])
187 total = int(info["vg_size"])
188 free = int(info["vg_free"])
189 snaps = int(info["snap_count"])
190 return PoolStats(total=total, used=total - free, free=free, snapshots=snaps, volumes=vols)