vdsl_sync/infra/location.rs
1//! Location — 拠点の多態抽象。
2//!
3//! 各拠点は「何があるか」(scan) と「どこにファイルがあるか」(file_root) を知っている。
4//! Local/SSH/Cloud で処理内容が根本的に異なるため、trait による多態で実装を切り替える。
5//!
6//! # 層配置
7//!
8//! `Location` trait は infra層に配置する。
9//! 理由: 実装が RemoteShell, StorageBackend, ContentHasher 等の infra型に依存するため。
10//! Domain層の `LocationId` は値オブジェクト(識別子のみ)として残る。
11//!
12//! # 責務
13//!
14//! - `id()` → この拠点の識別子
15//! - `kind()` → 拠点の物理的分類(コスト推定に使用)
16//! - `file_root()` → ファイルのベースパス
17//! - `scanner()` → この拠点のスキャン能力(LocationScanner)
18//! - `ensure()` → 到達確認 + 外部ツールの確保(rclone等)
19
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23use async_trait::async_trait;
24
25use crate::domain::location::LocationId;
26use crate::infra::error::InfraError;
27use crate::infra::location_scanner::LocationScanner;
28
29/// 拠点の物理的分類。
30///
31/// `SdkImplBuilder::build()` でルートコストを自動推定するために使用する。
32/// 2拠点間の転送コストは、双方の `LocationKind` の組み合わせで決まる:
33///
34/// | src → dest | コスト | 根拠 |
35/// |---|---|---|
36/// | Local → Remote | 1.0 | LAN/SSH、低レイテンシ |
37/// | Remote → Cloud | 2.0 | DC帯域、中速 |
38/// | Local → Cloud | 5.0 | 家庭回線アップロード、低速 |
39/// | Cloud → Remote | 2.0 | DC帯域、中速 |
40/// | Cloud → Local | 5.0 | 家庭回線ダウンロード |
41/// | Remote → Local | 1.0 | LAN/SSH |
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum LocationKind {
44 /// ローカルファイルシステム(開発マシン等)。
45 Local,
46 /// SSH経由リモートホスト(GPU Pod, NAS等)。データセンター帯域。
47 Remote,
48 /// クラウドストレージ(B2, S3等)。オブジェクトストア。
49 Cloud,
50}
51
52/// 拠点の多態抽象。
53///
54/// 各拠点は自分のスキャン方法を知っている:
55/// - Local: walkdir + ContentHasher
56/// - SSH: RemoteShell + batch_inspect
57/// - Cloud: StorageBackend.list() (metadata only)
58///
59/// `Location` trait 実装を `SdkImplBuilder::location()` に渡すことで、
60/// Scanner と Route の整合性が保証される。
61/// `kind()` は `SdkImplBuilder::build()` でルートコストの自動推定に使用される。
62#[async_trait]
63pub trait Location: Send + Sync {
64 /// この拠点の識別子。
65 fn id(&self) -> &LocationId;
66
67 /// 拠点の物理的分類。
68 ///
69 /// ルート間コスト推定に使用される。
70 fn kind(&self) -> LocationKind;
71
72 /// ファイルのベースパス。
73 ///
74 /// Local: `/Users/.../output`
75 /// Pod: `/workspace/comfyui/output`
76 /// Cloud: `vdsl/output`
77 fn file_root(&self) -> &Path;
78
79 /// この拠点のスキャナーを返す。
80 ///
81 /// 各実装が自分のスキャン方法に応じたLocationScannerを構築して返す。
82 fn scanner(&self) -> Arc<dyn LocationScanner>;
83
84 /// 拠点の到達可能性を検証し、必要な外部ツールを確保する。
85 ///
86 /// sync開始前に全Locationに対して呼ばれる。
87 /// - Local: file_rootの存在確認(なければ作成)
88 /// - SSH: SSH接続テスト
89 /// - Cloud: rcloneバイナリ確認 + バケット接続テスト
90 ///
91 /// 失敗時は早期エラーで、数分かかるscanを無駄にしない。
92 async fn ensure(&self) -> Result<(), InfraError>;
93}
94
95// =============================================================================
96// LocalLocation
97// =============================================================================
98
99use crate::infra::hasher::ContentHasher;
100use crate::infra::location_scanner::LocalScanner;
101
102/// ローカルファイルシステムの拠点。
103///
104/// walkdir + ContentHasher でスキャンする。
105pub struct LocalLocation {
106 id: LocationId,
107 root: PathBuf,
108 hasher: Arc<dyn ContentHasher>,
109}
110
111impl LocalLocation {
112 /// Create a `LocalLocation` with the canonical `"local"` [`LocationId`].
113 ///
114 /// # Arguments
115 ///
116 /// * `root` - Local filesystem path used as `file_root` for scan and route resolution.
117 /// * `hasher` - Shared content hasher for change detection.
118 ///
119 /// # Returns
120 ///
121 /// A `LocalLocation` identified as `"local"`.
122 ///
123 /// For multiple `LocalLocation`s with distinct IDs, use [`Self::new_with_id`].
124 pub fn new(root: PathBuf, hasher: Arc<dyn ContentHasher>) -> Self {
125 Self::new_with_id(LocationId::local(), root, hasher)
126 }
127
128 /// Create a `LocalLocation` with an arbitrary [`LocationId`].
129 ///
130 /// Useful when registering multiple local roots as separate locations
131 /// (e.g. `output` vs `projects`). The caller is responsible for ensuring
132 /// the `LocationId` is unique within a single [`crate::application::sdk_impl::SdkImplBuilder`].
133 ///
134 /// # Arguments
135 ///
136 /// * `id` - Location identifier. Must be unique among all locations registered
137 /// with the same builder. Constructed via [`LocationId::new`].
138 /// * `root` - Local filesystem path used as `file_root` for scan and route resolution.
139 /// * `hasher` - Shared content hasher for change detection.
140 ///
141 /// # Returns
142 ///
143 /// A `LocalLocation` with the provided `id` and `root`.
144 pub fn new_with_id(id: LocationId, root: PathBuf, hasher: Arc<dyn ContentHasher>) -> Self {
145 Self { id, root, hasher }
146 }
147}
148
149#[async_trait]
150impl Location for LocalLocation {
151 fn id(&self) -> &LocationId {
152 &self.id
153 }
154
155 fn kind(&self) -> LocationKind {
156 LocationKind::Local
157 }
158
159 fn file_root(&self) -> &Path {
160 &self.root
161 }
162
163 fn scanner(&self) -> Arc<dyn LocationScanner> {
164 Arc::new(LocalScanner::new(
165 self.id.clone(),
166 self.root.clone(),
167 self.hasher.clone(),
168 ))
169 }
170
171 async fn ensure(&self) -> Result<(), InfraError> {
172 if !self.root.exists() {
173 std::fs::create_dir_all(&self.root).map_err(|e| {
174 InfraError::Init(format!(
175 "local file_root '{}' does not exist and could not be created: {e}",
176 self.root.display()
177 ))
178 })?;
179 }
180 if !self.root.is_dir() {
181 return Err(InfraError::Init(format!(
182 "local file_root '{}' exists but is not a directory",
183 self.root.display()
184 )));
185 }
186 Ok(())
187 }
188}
189
190// =============================================================================
191// SshLocation
192// =============================================================================
193
194use crate::infra::location_scanner::SshScanner;
195use crate::infra::shell::RemoteShell;
196
197/// SSH経由リモートホストの拠点。
198///
199/// RemoteShell.batch_inspect() でスキャンする。
200pub struct SshLocation {
201 id: LocationId,
202 root: PathBuf,
203 shell: Arc<dyn RemoteShell>,
204}
205
206impl SshLocation {
207 pub fn new(id: LocationId, root: PathBuf, shell: Arc<dyn RemoteShell>) -> Self {
208 Self { id, root, shell }
209 }
210}
211
212#[async_trait]
213impl Location for SshLocation {
214 fn id(&self) -> &LocationId {
215 &self.id
216 }
217
218 fn kind(&self) -> LocationKind {
219 LocationKind::Remote
220 }
221
222 fn file_root(&self) -> &Path {
223 &self.root
224 }
225
226 fn scanner(&self) -> Arc<dyn LocationScanner> {
227 Arc::new(SshScanner::new(
228 self.id.clone(),
229 self.root.clone(),
230 self.shell.clone(),
231 ))
232 }
233
234 async fn ensure(&self) -> Result<(), InfraError> {
235 let output = self.shell.exec(&["echo", "pong"], Some(30)).await?;
236 if !output.success {
237 return Err(InfraError::Init(format!(
238 "SSH location '{}' unreachable (exit {}): {}",
239 self.id,
240 output.exit_code.unwrap_or(-1),
241 output.stderr.trim()
242 )));
243 }
244 Ok(())
245 }
246}
247
248// =============================================================================
249// CloudLocation
250// =============================================================================
251
252use crate::infra::backend::StorageBackend;
253use crate::infra::location_scanner::CloudScanner;
254
255/// Cloud storage の拠点。
256///
257/// StorageBackend.list() でメタデータのみ取得する。
258/// コンテンツハッシュはダウンロードが必要なため取得しない。
259pub struct CloudLocation {
260 id: LocationId,
261 root: PathBuf,
262 backend: Arc<dyn StorageBackend>,
263}
264
265impl CloudLocation {
266 pub fn new(id: LocationId, root: PathBuf, backend: Arc<dyn StorageBackend>) -> Self {
267 Self { id, root, backend }
268 }
269}
270
271#[async_trait]
272impl Location for CloudLocation {
273 fn id(&self) -> &LocationId {
274 &self.id
275 }
276
277 fn kind(&self) -> LocationKind {
278 LocationKind::Cloud
279 }
280
281 fn file_root(&self) -> &Path {
282 &self.root
283 }
284
285 fn scanner(&self) -> Arc<dyn LocationScanner> {
286 Arc::new(CloudScanner::new(
287 self.id.clone(),
288 self.root.clone(),
289 self.backend.clone(),
290 ))
291 }
292
293 async fn ensure(&self) -> Result<(), InfraError> {
294 self.backend.ensure().await.map_err(|e| {
295 InfraError::Init(format!("cloud location '{}' ensure failed: {e}", self.id))
296 })
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use std::path::PathBuf;
303 use std::sync::Arc;
304
305 use super::*;
306 use crate::domain::location::LocationId;
307 use crate::infra::hasher::Djb2Hasher;
308
309 fn make_hasher() -> Arc<dyn ContentHasher> {
310 Arc::new(Djb2Hasher)
311 }
312
313 // T1: happy path — new_with_id stores the provided LocationId
314 #[test]
315 fn new_with_id_stores_custom_id() {
316 let root = PathBuf::from("/tmp/projects");
317 // SAFETY: "projects" is valid (lowercase alphanum), unwrap cannot panic
318 let id = LocationId::new("projects").unwrap();
319 let loc = LocalLocation::new_with_id(id, root, make_hasher());
320 assert_eq!(loc.id().as_str(), "projects");
321 }
322
323 // T1: happy path — new_with_id produces LocationKind::Local and correct file_root
324 #[test]
325 fn new_with_id_kind_and_file_root() {
326 let root = PathBuf::from("/tmp/projects");
327 // SAFETY: "my-loc" is valid (lowercase alphanum + hyphen), unwrap cannot panic
328 let id = LocationId::new("my-loc").unwrap();
329 let loc = LocalLocation::new_with_id(id, root.clone(), make_hasher());
330 assert_eq!(loc.kind(), LocationKind::Local);
331 assert_eq!(loc.file_root(), root.as_path());
332 }
333
334 // T2: boundary — existing new() still produces "local" id (delegation compatibility)
335 #[test]
336 fn new_delegates_to_local_id() {
337 let root = PathBuf::from("/tmp/output");
338 let loc = LocalLocation::new(root, make_hasher());
339 assert_eq!(loc.id().as_str(), "local");
340 assert_eq!(loc.kind(), LocationKind::Local);
341 }
342
343 // T3: error path — LocationId::new rejects invalid input (uppercase, space)
344 #[test]
345 fn location_id_rejects_invalid_chars() {
346 assert!(LocationId::new("Invalid").is_err());
347 assert!(LocationId::new("has space").is_err());
348 assert!(LocationId::new("").is_err());
349 }
350}