decdn_common/cli/origin.rs
1//! Arguments for the `decdn origin` command group (issue #1904).
2//!
3//! `origin import` seeds a cache **origin store** with local content: for each
4//! blob it writes the sharded data object `{hex[0..2]}/{hex}` and its sibling
5//! pre-order bao outboard `{hex}.obao4` (per ADR 037 §Origin-tier pull-through),
6//! the exact layout the daemon reads back. It is offline, config-free local
7//! work — no running daemon, no `node.toml`, no chain — so it lives in its own
8//! group rather than under `decdn node` (whose every subcommand talks to a
9//! running node over the loopback admin surface, `appendix-local-admin-http.md`).
10//!
11//! `--to` is a local filesystem directory: the store is written to disk and
12//! nowhere else. An S3 origin needs no separate writer, because the fs and S3
13//! object layouts are byte-identical — import to a directory and `aws s3 sync`
14//! it up to the bucket.
15
16use std::path::PathBuf;
17
18use clap::{Args, Subcommand};
19
20/// Top-level `decdn origin` group.
21#[derive(Args, Debug)]
22pub struct OriginArgs {
23 /// The `decdn origin` subcommand to run.
24 #[command(subcommand)]
25 pub cmd: OriginCommand,
26}
27
28/// Subcommands under `decdn origin`.
29#[derive(Subcommand, Debug)]
30pub enum OriginCommand {
31 /// Import local content into a cache origin store, writing each blob's
32 /// sharded data object and its `{hex}.obao4` outboard in the layout the
33 /// daemon reads. A directory is walked, every regular file imported, and a
34 /// canonical bundle manifest emitted (with `--dry-run`, printed to stdout
35 /// instead) and itself imported, so the whole tree is retrievable by one
36 /// bundle hash.
37 Import(OriginImportArgs),
38}
39
40/// `decdn origin import` — seed a cache origin store from a local file or tree.
41// The flags (`--move`, `--force`, `--follow-symlinks`, `--json`) are independent
42// operator toggles on one offline command, not a state machine — a bitflags or
43// two-variant-enum refactor would only obscure the clap surface.
44#[expect(
45 clippy::struct_excessive_bools,
46 reason = "independent CLI flags, not modelled state"
47)]
48#[derive(Args, Debug)]
49pub struct OriginImportArgs {
50 /// Local file or directory to import. A directory is walked recursively
51 /// (see `--follow-symlinks` / `--exclude`); every regular file becomes a
52 /// content-addressed blob in the target.
53 #[arg(short = 'i', long, value_name = "PATH")]
54 pub input: PathBuf,
55
56 /// Directory to populate — a local filesystem origin store rooted here,
57 /// created if absent. This is the only write backend; pass the path
58 /// directly (`--to /var/lib/decdn/origin`), with no scheme prefix.
59 ///
60 /// To populate an S3 origin, import to a local directory and then
61 /// `aws s3 sync <dir> s3://<bucket>/<prefix>`: the fs and S3 object layouts
62 /// are byte-identical, so the sync needs no renaming. An `s3://` or
63 /// `http(s)://` value is rejected with that recipe rather than treated as a
64 /// path.
65 ///
66 /// Required unless `--dry-run`.
67 ///
68 /// A `PathBuf`, not a `String`, so a non-UTF-8 directory path is accepted
69 /// on Unix like `--input` is. Prefix sniffing (`s3://` / `http(s)://` /
70 /// `fs:`) runs on the UTF-8 view; a non-UTF-8 path cannot be one of those,
71 /// so it is unambiguously a filesystem directory.
72 #[arg(long, value_name = "DIR")]
73 pub to: Option<PathBuf>,
74
75 /// Move each source file into the target instead of copying it. Across
76 /// filesystems this falls back to copy-then-unlink. A source whose content
77 /// is already present in the target is removed too (the blob is there).
78 #[arg(long = "move")]
79 pub move_source: bool,
80
81 /// When importing a directory, also write the canonical bundle manifest to
82 /// this file (in addition to importing the manifest blob into the target).
83 /// Ignored for a single-file import.
84 #[arg(long, value_name = "FILE")]
85 pub bundle: Option<PathBuf>,
86
87 /// Overwrite a target object even when one already exists at its hash. By
88 /// default an existing object is left in place (content-addressed: the same
89 /// hash is the same bytes); a size mismatch is refused unless `--force`.
90 #[arg(long)]
91 pub force: bool,
92
93 /// Follow symlinks during a directory walk. Off by default. Even when on, a
94 /// symlink resolving outside the input root is a hard error: a manifest's
95 /// relative-path field cannot describe an external target.
96 #[arg(long)]
97 pub follow_symlinks: bool,
98
99 /// Glob pattern of paths to exclude during a directory walk, matched against
100 /// the POSIX relative path (`a/b.txt`, never the absolute path). Repeatable
101 /// — every pattern is OR-ed together.
102 #[arg(long = "exclude", value_name = "GLOB", action = clap::ArgAction::Append)]
103 pub exclude: Vec<String>,
104
105 /// Prefix every manifest entry's path with this folder, so a `bundle pull`
106 /// materializes the whole bundle under one directory (`<out>/<subfolder>/…`)
107 /// rather than spilling its files directly into the output root. Must be a
108 /// relative POSIX path with `/` separators — `..`, absolute paths, root
109 /// prefixes, and backslashes are rejected; nesting (`a/b`) is allowed.
110 /// Applies to a single-file import too (the one entry becomes
111 /// `<subfolder>/<name>`).
112 #[arg(long, value_name = "DIR")]
113 pub subfolder: Option<String>,
114
115 /// Emit a one-line JSON status report instead of a human summary. Written to
116 /// stdout normally; under `--dry-run` it goes to stderr, because stdout then
117 /// carries the canonical manifest bytes. `origin` is the `--to` target, or
118 /// the literal `(dry-run)` when a dry run runs without `--to`.
119 /// Shape:
120 /// `{"imported":<n>,"bytes":<total>,"origin":"<target>","files":{"<path>":"b3:<hex>",...},"bundle_hash":"b3:<hex>"|null,"moved":<bool>,"optimized":<bool>,"chunks_total":<n>,"skipped_symlinks":<n>}`.
121 #[arg(long)]
122 pub json: bool,
123
124 /// Content-defined-chunk each file and record the chunk boundaries as
125 /// `{hash,size}` hints in the manifest entry; each file is still stored
126 /// as one whole-file blob. A pull uses the hints to splice ranges it
127 /// already holds instead of re-fetching them. Off by default (no hints).
128 #[arg(long)]
129 pub optimize: bool,
130
131 /// Target average chunk size (`--optimize` only). The primary dial. Must be
132 /// a power of two, at most 4 MiB (fastcdc's ceiling). Defaults to 4 MiB when
133 /// `--optimize` is set (applied in the import wiring, not by clap, so an
134 /// explicit `--chunk-avg` without `--optimize` is a detectable error).
135 #[arg(long, value_name = "SIZE", value_parser = super::parse_byte_size)]
136 pub chunk_avg: Option<u64>,
137
138 /// Minimum chunk size override (`--optimize` only). Defaults to `avg/4`.
139 /// At least 1 MiB (the payment interval), at most 1 MiB (fastcdc's ceiling).
140 #[arg(long, value_name = "SIZE", value_parser = super::parse_byte_size)]
141 pub chunk_min: Option<u64>,
142
143 /// Maximum chunk size override (`--optimize` only). Defaults to `2*avg`.
144 /// At most 16 MiB (fastcdc's ceiling).
145 #[arg(long, value_name = "SIZE", value_parser = super::parse_byte_size)]
146 pub chunk_max: Option<u64>,
147
148 /// Compute and emit the manifest without writing any blobs. Prints the
149 /// canonical manifest bytes to stdout (status goes to stderr). Makes `--to`
150 /// optional — this is the local "just make me a manifest" path.
151 #[arg(long)]
152 pub dry_run: bool,
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
157mod tests;