Skip to main content

btrfs_cli/filesystem/
resize.rs

1use crate::{
2    RunContext, Runnable,
3    util::{is_mounted, open_path, parse_size_with_suffix},
4};
5use anyhow::{Context, Result, bail};
6use btrfs_disk::{
7    items::DeviceItem,
8    tree::{DiskKey, KeyType},
9};
10use btrfs_transaction::{
11    filesystem::Filesystem,
12    path::BtrfsPath,
13    search::{self, SearchIntent},
14    transaction::Transaction,
15};
16use btrfs_uapi::filesystem::{ResizeAmount, ResizeArgs, resize};
17use clap::Parser;
18use std::{
19    fs::OpenOptions,
20    os::unix::io::AsFd,
21    path::{Path, PathBuf},
22};
23
24/// Resize a mounted btrfs filesystem
25#[derive(Parser, Debug)]
26pub struct FilesystemResizeCommand {
27    /// Wait if there is another exclusive operation running, otherwise error
28    #[clap(long)]
29    pub enqueue: bool,
30
31    /// Resize a filesystem stored in a file image (unmounted)
32    #[clap(long)]
33    pub offline: bool,
34
35    /// New size for the filesystem, e.g. "1G", "+512M", "-1G", "max", "cancel",
36    /// or "devid:ID:SIZE" to target a specific device
37    pub size: String,
38
39    pub path: PathBuf,
40}
41
42fn parse_resize_amount(s: &str) -> Result<ResizeAmount> {
43    if s == "cancel" {
44        return Ok(ResizeAmount::Cancel);
45    }
46    if s == "max" {
47        return Ok(ResizeAmount::Max);
48    }
49    let (modifier, rest) = if let Some(r) = s.strip_prefix('+') {
50        (1i32, r)
51    } else if let Some(r) = s.strip_prefix('-') {
52        (-1i32, r)
53    } else {
54        (0i32, s)
55    };
56    let bytes = parse_size_with_suffix(rest)?;
57    Ok(match modifier {
58        1 => ResizeAmount::Add(bytes),
59        -1 => ResizeAmount::Sub(bytes),
60        _ => ResizeAmount::Set(bytes),
61    })
62}
63
64fn parse_resize_args(s: &str) -> Result<ResizeArgs> {
65    if let Some(colon) = s.find(':')
66        && let Ok(devid) = s[..colon].parse::<u64>()
67    {
68        let amount = parse_resize_amount(&s[colon + 1..])?;
69        return Ok(ResizeArgs::new(amount).with_devid(devid));
70    }
71    Ok(ResizeArgs::new(parse_resize_amount(s)?))
72}
73
74impl Runnable for FilesystemResizeCommand {
75    fn run(&self, ctx: &RunContext) -> Result<()> {
76        if self.offline {
77            if self.enqueue {
78                bail!("--enqueue is not compatible with --offline");
79            }
80            return run_offline(&self.path, &self.size, ctx);
81        }
82
83        if self.enqueue {
84            bail!("--enqueue is not yet implemented");
85        }
86
87        let args = parse_resize_args(&self.size).with_context(|| {
88            format!("invalid resize argument: '{}'", self.size)
89        })?;
90
91        let file = open_path(&self.path)?;
92
93        resize(file.as_fd(), args).with_context(|| {
94            format!("resize failed on '{}'", self.path.display())
95        })?;
96
97        Ok(())
98    }
99
100    fn supports_dry_run(&self) -> bool {
101        // Only the offline path honors --dry-run; the online ioctl
102        // path always commits.
103        self.offline
104    }
105}
106
107/// Resize a btrfs filesystem on an unmounted image or block device.
108///
109/// Only grow is supported. Only single-device filesystems are
110/// supported. The filesystem must not be mounted. The amount is
111/// parsed with [`parse_resize_amount`]; the `cancel` keyword is
112/// rejected (there is no pending operation to cancel). A `devid:`
113/// prefix is accepted as long as it names the sole device.
114///
115/// The operation updates `DEV_ITEM.total_bytes` in the chunk tree
116/// and `superblock.total_bytes`, commits a transaction, and
117/// finally truncates the backing file to the new size when the
118/// target is a regular file (block devices are left alone).
119#[allow(clippy::too_many_lines)]
120fn run_offline(path: &Path, amount: &str, ctx: &RunContext) -> Result<()> {
121    if is_mounted(path) {
122        bail!("{} must not be mounted to use --offline", path.display());
123    }
124
125    let args = parse_resize_args(amount)
126        .with_context(|| format!("invalid resize argument: '{amount}'"))?;
127
128    if matches!(args.amount, ResizeAmount::Cancel) {
129        bail!("cannot cancel an offline resize");
130    }
131
132    let file = OpenOptions::new()
133        .read(true)
134        .write(true)
135        .open(path)
136        .with_context(|| format!("failed to open '{}'", path.display()))?;
137
138    let metadata = file
139        .metadata()
140        .with_context(|| format!("failed to stat '{}'", path.display()))?;
141    let is_regular_file = metadata.file_type().is_file();
142
143    let mut fs = Filesystem::open(file).with_context(|| {
144        format!("failed to open filesystem on '{}'", path.display())
145    })?;
146
147    if fs.superblock.num_devices != 1 {
148        bail!(
149            "multi-device filesystems are not supported with --offline ({} devices)",
150            fs.superblock.num_devices
151        );
152    }
153
154    let sectorsize = u64::from(fs.superblock.sectorsize);
155    let old_total = fs.superblock.total_bytes;
156    let devid = fs.superblock.dev_item.devid;
157    let old_device_bytes = fs.superblock.dev_item.total_bytes;
158
159    if let Some(requested_devid) = args.devid
160        && requested_devid != devid
161    {
162        bail!(
163            "invalid device id {requested_devid} (only devid {devid} is present)"
164        );
165    }
166
167    // Resolve the requested amount to an absolute new device size.
168    let new_device_bytes = match args.amount {
169        ResizeAmount::Set(bytes) => bytes,
170        ResizeAmount::Add(bytes) => old_device_bytes
171            .checked_add(bytes)
172            .context("resize overflow")?,
173        ResizeAmount::Sub(_) => {
174            bail!("offline resize does not support shrinking")
175        }
176        ResizeAmount::Max => {
177            // For images and block devices, "max" means the full
178            // backing size. For block devices we would need to
179            // query the partition size via ioctl; for regular
180            // files we use the file length.
181            if is_regular_file {
182                metadata.len()
183            } else {
184                bail!("--offline max is only supported on regular file images");
185            }
186        }
187        ResizeAmount::Cancel => unreachable!("rejected above"),
188    };
189
190    // Round down to the sector size, matching btrfs-progs.
191    let new_device_bytes = (new_device_bytes / sectorsize) * sectorsize;
192    if new_device_bytes < old_device_bytes {
193        bail!("offline resize does not support shrinking");
194    }
195    if new_device_bytes == old_device_bytes {
196        if !ctx.quiet {
197            println!(
198                "{}: already at the requested size ({} bytes)",
199                path.display(),
200                old_device_bytes
201            );
202        }
203        return Ok(());
204    }
205
206    let diff = new_device_bytes - old_device_bytes;
207    let new_total_bytes = old_total
208        .checked_add(diff)
209        .context("superblock total_bytes overflow")?;
210
211    if !ctx.quiet {
212        println!(
213            "resize '{}' from {} to {} ({:+} bytes){}",
214            path.display(),
215            old_device_bytes,
216            new_device_bytes,
217            diff.cast_signed(),
218            if ctx.dry_run { " [dry-run]" } else { "" },
219        );
220    }
221
222    if ctx.dry_run {
223        return Ok(());
224    }
225
226    // Start a transaction and patch the DEV_ITEM in the chunk tree.
227    let mut trans =
228        Transaction::start(&mut fs).context("failed to start transaction")?;
229
230    let key = DiskKey {
231        objectid: 1, // BTRFS_DEV_ITEMS_OBJECTID
232        key_type: KeyType::DeviceItem,
233        offset: devid,
234    };
235    let mut bpath = BtrfsPath::new();
236    let found = search::search_slot(
237        Some(&mut trans),
238        &mut fs,
239        3, // chunk tree
240        &key,
241        &mut bpath,
242        SearchIntent::ReadOnly,
243        true, // COW
244    )
245    .context("failed to search chunk tree for DEV_ITEM")?;
246    if !found {
247        bpath.release();
248        bail!("DEV_ITEM for devid {devid} not found in chunk tree");
249    }
250
251    {
252        let leaf = bpath.nodes[0]
253            .as_mut()
254            .context("DEV_ITEM search returned no leaf")?;
255        let slot = bpath.slots[0];
256        let data = leaf.item_data(slot);
257        let mut item = DeviceItem::parse(data).with_context(|| {
258            format!("failed to parse DEV_ITEM for devid {devid}")
259        })?;
260        item.total_bytes = new_device_bytes;
261        let mut buf = Vec::with_capacity(data.len());
262        item.write_bytes(&mut buf);
263        let target = leaf.item_data_mut(slot);
264        if target.len() < buf.len() {
265            bpath.release();
266            bail!("DEV_ITEM payload smaller than expected");
267        }
268        target[..buf.len()].copy_from_slice(&buf);
269        fs.mark_dirty(leaf);
270    }
271    bpath.release();
272
273    // Update the in-memory superblock. The commit step writes it
274    // back to all mirrors; the embedded dev_item is kept in sync so
275    // a subsequent mount sees consistent bootstrap values.
276    fs.superblock.total_bytes = new_total_bytes;
277    fs.superblock.dev_item.total_bytes = new_device_bytes;
278
279    trans
280        .commit(&mut fs)
281        .context("failed to commit transaction")?;
282    fs.sync().context("failed to sync to disk")?;
283
284    // For regular file images, extend the file length to match.
285    // Block devices are sized by the underlying partition, so we
286    // leave them alone.
287    if is_regular_file {
288        let f = fs.reader_mut().inner_mut();
289        f.set_len(new_device_bytes).with_context(|| {
290            format!(
291                "failed to resize backing file '{}' to {new_device_bytes}",
292                path.display()
293            )
294        })?;
295    }
296
297    Ok(())
298}