Author:
Hash:
Timestamp:
+177 -30 +/-9 browse
Kevin Schoon [me@kevinschoon.com]
7156b7188bf7bae8d80caa58fa9aafe77d4e8344
Thu, 17 Apr 2025 16:10:45 +0000 (1 month ago)
1 | diff --git a/Cargo.lock b/Cargo.lock |
2 | index 3dfa26a..ef5e606 100644 |
3 | --- a/Cargo.lock |
4 | +++ b/Cargo.lock |
5 | @@ -698,6 +698,7 @@ dependencies = [ |
6 | "tracing", |
7 | "tracing-subscriber", |
8 | "uuid", |
9 | + "walkdir", |
10 | ] |
11 | |
12 | [[package]] |
13 | @@ -850,6 +851,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
14 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" |
15 | |
16 | [[package]] |
17 | + name = "same-file" |
18 | + version = "1.0.6" |
19 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
20 | + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" |
21 | + dependencies = [ |
22 | + "winapi-util", |
23 | + ] |
24 | + |
25 | + [[package]] |
26 | name = "scopeguard" |
27 | version = "1.2.0" |
28 | source = "registry+https://github.com/rust-lang/crates.io-index" |
29 | @@ -1213,6 +1223,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
30 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" |
31 | |
32 | [[package]] |
33 | + name = "walkdir" |
34 | + version = "2.5.0" |
35 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
36 | + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" |
37 | + dependencies = [ |
38 | + "same-file", |
39 | + "winapi-util", |
40 | + ] |
41 | + |
42 | + [[package]] |
43 | name = "wasi" |
44 | version = "0.11.0+wasi-snapshot-preview1" |
45 | source = "registry+https://github.com/rust-lang/crates.io-index" |
46 | @@ -1244,6 +1264,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
47 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" |
48 | |
49 | [[package]] |
50 | + name = "winapi-util" |
51 | + version = "0.1.9" |
52 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
53 | + checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" |
54 | + dependencies = [ |
55 | + "windows-sys", |
56 | + ] |
57 | + |
58 | + [[package]] |
59 | name = "winapi-x86_64-pc-windows-gnu" |
60 | version = "0.4.0" |
61 | source = "registry+https://github.com/rust-lang/crates.io-index" |
62 | diff --git a/Cargo.toml b/Cargo.toml |
63 | index a3810c2..1348aac 100644 |
64 | --- a/Cargo.toml |
65 | +++ b/Cargo.toml |
66 | @@ -27,6 +27,7 @@ hex-literal = "1.0.0" |
67 | base16ct = { version = "0.2.0", features = ["alloc"] } |
68 | base64 = "0.22.1" |
69 | askama = { version = "0.13.1", features = ["serde_json"], optional = true} |
70 | + walkdir = "2.5.0" |
71 | |
72 | [dev-dependencies] |
73 | tokio = { version = "1.44.1", features = ["full"] } |
74 | diff --git a/src/address.rs b/src/address.rs |
75 | index 342dde5..9f48d9d 100644 |
76 | --- a/src/address.rs |
77 | +++ b/src/address.rs |
78 | @@ -23,7 +23,21 @@ fn digest_prefix(digest: &Digest) -> &str { |
79 | } |
80 | } |
81 | |
82 | - #[derive(Debug, PartialEq, Eq)] |
83 | + #[derive(Copy, Clone, Debug, PartialEq, Eq)] |
84 | + pub enum Kind { |
85 | + Tag, |
86 | + TagDirectory, |
87 | + Reference, |
88 | + TempBlob, |
89 | + Blob, |
90 | + BlobsRoot, |
91 | + ManifestRevision, |
92 | + LayerLink, |
93 | + Repository, |
94 | + RepositoriesRoot, |
95 | + } |
96 | + |
97 | + #[derive(Debug, Hash, PartialEq, Eq)] |
98 | pub enum Address { |
99 | Tag { |
100 | namespace: Namespace, |
101 | @@ -43,6 +57,7 @@ pub enum Address { |
102 | Blob { |
103 | digest: Digest, |
104 | }, |
105 | + BlobsRoot, |
106 | ManifestRevision { |
107 | namespace: Namespace, |
108 | digest: Digest, |
109 | @@ -54,6 +69,7 @@ pub enum Address { |
110 | Repository { |
111 | namespace: Namespace, |
112 | }, |
113 | + RepositoriesRoot, |
114 | } |
115 | |
116 | impl Display for Address { |
117 | @@ -71,10 +87,41 @@ impl Address { |
118 | pub fn is_link(&self) -> bool { |
119 | self.components().last().is_some_and(|part| part == "link") |
120 | } |
121 | + |
122 | pub fn is_data(&self) -> bool { |
123 | self.components().last().is_some_and(|part| part == "data") |
124 | } |
125 | |
126 | + pub fn kind(&self) -> Kind { |
127 | + match self { |
128 | + Address::Tag { |
129 | + namespace: _, |
130 | + name: _, |
131 | + } => Kind::Tag, |
132 | + Address::TagDirectory { namespace: _ } => Kind::TagDirectory, |
133 | + Address::Reference { |
134 | + namespace: _, |
135 | + digest: _, |
136 | + } => Kind::Reference, |
137 | + Address::TempBlob { |
138 | + uuid: _, |
139 | + namespace: _, |
140 | + } => Kind::TempBlob, |
141 | + Address::Blob { digest: _ } => Kind::Blob, |
142 | + Address::ManifestRevision { |
143 | + namespace: _, |
144 | + digest: _, |
145 | + } => Kind::ManifestRevision, |
146 | + Address::LayerLink { |
147 | + namespace: _, |
148 | + digest: _, |
149 | + } => Kind::LayerLink, |
150 | + Address::Repository { namespace: _ } => Kind::Repository, |
151 | + Address::BlobsRoot => Kind::BlobsRoot, |
152 | + Address::RepositoriesRoot => Kind::RepositoriesRoot, |
153 | + } |
154 | + } |
155 | + |
156 | fn segment_ns( |
157 | parts: impl IntoIterator<Item = String>, |
158 | ) -> Result<(Namespace, Vec<String>), crate::error::Error> { |
159 | @@ -159,6 +206,8 @@ impl Address { |
160 | .split("/") |
161 | .map(|part| part.to_string()), |
162 | ), |
163 | + Address::BlobsRoot => vec![String::from("blobs")], |
164 | + Address::RepositoriesRoot => vec![String::from("repositories")], |
165 | } |
166 | } |
167 | } |
168 | @@ -171,6 +220,9 @@ impl FromStr for Address { |
169 | let first = split.pop_front().unwrap(); |
170 | match first.as_str() { |
171 | "blobs" => { |
172 | + if split.is_empty() { |
173 | + return Ok(Address::BlobsRoot); |
174 | + } |
175 | // example: sha256/57/57f2ae062b76cff6f5a511fe6f907decfdefd6495e6afa31c44e0a6a1eca146f/data |
176 | let algorithm = split.pop_front().unwrap(); |
177 | split.pop_front(); |
178 | @@ -179,12 +231,15 @@ impl FromStr for Address { |
179 | Ok(Address::Blob { digest }) |
180 | } |
181 | "repositories" => { |
182 | + if split.is_empty() { |
183 | + return Ok(Address::RepositoriesRoot); |
184 | + } |
185 | let (namespace, rest) = Address::segment_ns(split)?; |
186 | // println!("{} {:?}", namespace, rest); |
187 | if rest.len() == 1 && rest[0] == "_tags" { |
188 | // example: /{namespace}/tags |
189 | Ok(Address::TagDirectory { namespace }) |
190 | - } else if rest.len() > 2 |
191 | + } else if rest.len() > 3 |
192 | && rest[0] == "_tags" |
193 | && rest[2] == "current" |
194 | && rest[3] == "link" |
195 | @@ -287,6 +342,8 @@ mod test { |
196 | "repositories/hello/world///fuu/bar/tags", |
197 | "repositories/fuu/bar", |
198 | "repositories/fuu", |
199 | + "repositories", |
200 | + "blobs", |
201 | ] |
202 | .iter() |
203 | .for_each(|item| { |
204 | diff --git a/src/axum/mod.rs b/src/axum/mod.rs |
205 | index b44f1c8..f7f09d1 100644 |
206 | --- a/src/axum/mod.rs |
207 | +++ b/src/axum/mod.rs |
208 | @@ -25,7 +25,7 @@ mod paths; |
209 | pub mod web; |
210 | |
211 | #[derive(Clone)] |
212 | - pub(crate) struct AppState { |
213 | + pub struct AppState { |
214 | pub oci: OciInterface, |
215 | } |
216 | |
217 | diff --git a/src/axum/web/router.rs b/src/axum/web/router.rs |
218 | index 9d3012d..e565c4e 100644 |
219 | --- a/src/axum/web/router.rs |
220 | +++ b/src/axum/web/router.rs |
221 | @@ -2,6 +2,7 @@ use std::sync::Arc; |
222 | |
223 | use axum::{ |
224 | Extension, Router, |
225 | + extract::State, |
226 | response::{Html, Response}, |
227 | routing::get, |
228 | }; |
229 | @@ -26,11 +27,17 @@ pub async fn stylesheet() -> Response { |
230 | res |
231 | } |
232 | |
233 | - pub async fn index() -> Result<Html<String>, crate::axum::error::Error> { |
234 | + pub async fn index( |
235 | + State(state): State<Arc<AppState>>, |
236 | + ) -> Result<Html<String>, crate::axum::error::Error> { |
237 | let template = RepositoryIndex { |
238 | title: "Repositories", |
239 | repositories: vec![String::from("Hello")], |
240 | }; |
241 | + let namespaces = state.oci.list_namespaces().await?; |
242 | + namespaces.iter().for_each(|ns| { |
243 | + println!("NS: {}", ns); |
244 | + }); |
245 | Ok(Html::from(template.to_string())) |
246 | } |
247 | |
248 | diff --git a/src/namespace.rs b/src/namespace.rs |
249 | index b06f587..45019a9 100644 |
250 | --- a/src/namespace.rs |
251 | +++ b/src/namespace.rs |
252 | @@ -11,7 +11,7 @@ use crate::error::Error; |
253 | const NAME_REGEXP_MATCH: &str = r"^[a-z0-9]+(?:[._-][a-z0-9]+)*"; |
254 | |
255 | // TODO: Consider 255 char namespace limit - hostname length per spec docs |
256 | - #[derive(Clone, Debug, PartialEq, Eq)] |
257 | + #[derive(Clone, Debug, Hash, PartialEq, Eq)] |
258 | pub struct Namespace(Vec<String>); |
259 | |
260 | impl Namespace { |
261 | diff --git a/src/oci_interface.rs b/src/oci_interface.rs |
262 | index d9908d1..09df8e2 100644 |
263 | --- a/src/oci_interface.rs |
264 | +++ b/src/oci_interface.rs |
265 | @@ -11,7 +11,7 @@ use uuid::Uuid; |
266 | |
267 | use crate::{ |
268 | Namespace, TagOrDigest, |
269 | - address::Address, |
270 | + address::{Address, Kind}, |
271 | error::Error, |
272 | storage::{InnerStream, StorageIface}, |
273 | }; |
274 | @@ -336,7 +336,7 @@ impl OciInterface { |
275 | .enumerate() |
276 | .find_map(|(i, name)| if **name == tag_name { Some(i) } else { None }) |
277 | { |
278 | - items.split_at(last_pos+1).1 |
279 | + items.split_at(last_pos + 1).1 |
280 | } else { |
281 | items.as_slice() |
282 | } |
283 | @@ -356,4 +356,19 @@ impl OciInterface { |
284 | Ok(tag_list.build().unwrap()) |
285 | } |
286 | } |
287 | + |
288 | + pub async fn list_namespaces(&self) -> Result<Vec<Namespace>, Error> { |
289 | + let results = self |
290 | + .storage |
291 | + .find(&Address::RepositoriesRoot, Some(Kind::Repository)) |
292 | + .await |
293 | + .map_err(Error::Storage)?; |
294 | + let namespaces: Vec<Namespace> = results.iter().filter_map(|addr| { |
295 | + match addr { |
296 | + Address::Repository { namespace } => Some(namespace.clone()), |
297 | + _ => None |
298 | + } |
299 | + }).collect(); |
300 | + Ok(namespaces) |
301 | + } |
302 | } |
303 | diff --git a/src/storage.rs b/src/storage.rs |
304 | index 211d04f..ef19917 100644 |
305 | --- a/src/storage.rs |
306 | +++ b/src/storage.rs |
307 | @@ -1,9 +1,13 @@ |
308 | - use std::{io::Error as IoError, path::PathBuf, pin::Pin}; |
309 | + use std::{collections::HashSet, io::Error as IoError, path::PathBuf, pin::Pin}; |
310 | |
311 | use bytes::Bytes; |
312 | use futures::{Stream, stream::BoxStream}; |
313 | |
314 | - use crate::{address::Address, storage_fs::FileSystem}; |
315 | + use crate::{ |
316 | + Namespace, |
317 | + address::{Address, Kind}, |
318 | + storage_fs::FileSystem, |
319 | + }; |
320 | |
321 | #[derive(thiserror::Error, Debug)] |
322 | pub enum Error { |
323 | @@ -43,21 +47,22 @@ impl Stream for InnerStream { |
324 | pub trait StorageIface: Sync + Send { |
325 | /// List a single directory of objects |
326 | async fn list(&self, addr: &Address) -> Result<Vec<String>, Error>; |
327 | - // async fn stat(&self, addr: &Address) -> Result<Option<Object>, Error>; |
328 | - // async fn read_bytes(&self, addr: &Address) -> Result<Option<Vec<u8>>, Error>; |
329 | /// Check if an object exists at the given address |
330 | - async fn exists<'a>(&self, path: &Address) -> Result<bool, Error>; |
331 | + async fn exists(&self, path: &Address) -> Result<bool, Error>; |
332 | /// Write bytes to the address, truncating any existing object |
333 | - async fn write_all<'a>(&self, path: &Address, bytes: &[u8]) -> Result<(), Error>; |
334 | - |
335 | - /// write bytes to a file that has already been created |
336 | - async fn write<'a>(&self, path: &Address, bytes: &[u8]) -> Result<(), Error>; |
337 | - |
338 | - async fn mv<'a>(&self, src: &Address, dst: &Address) -> Result<(), Error>; |
339 | - // fn mv (&self, )std::future::Future<Output = ()> + Send |
340 | - async fn read<'a>(&self, src: &Address) -> Result<InnerStream, Error>; |
341 | - async fn read_bytes<'a>(&self, src: &Address) -> Result<Bytes, Error>; |
342 | - async fn delete<'a>(&self, src: &Address) -> Result<(), Error>; |
343 | + async fn write_all(&self, path: &Address, bytes: &[u8]) -> Result<(), Error>; |
344 | + /// Write bytes to a file that has already been created |
345 | + async fn write(&self, path: &Address, bytes: &[u8]) -> Result<(), Error>; |
346 | + /// Move a file from one address to another |
347 | + async fn mv(&self, src: &Address, dst: &Address) -> Result<(), Error>; |
348 | + /// Read an address to a stream |
349 | + async fn read(&self, src: &Address) -> Result<InnerStream, Error>; |
350 | + /// Read an address into memory |
351 | + async fn read_bytes(&self, src: &Address) -> Result<Bytes, Error>; |
352 | + /// Delete an object at the address |
353 | + async fn delete(&self, src: &Address) -> Result<(), Error>; |
354 | + /// Find objects in the underlying storage |
355 | + async fn find(&self, start: &Address, filter: Option<Kind>) -> Result<HashSet<Address>, Error>; |
356 | } |
357 | |
358 | #[derive(Debug, Clone)] |
359 | diff --git a/src/storage_fs.rs b/src/storage_fs.rs |
360 | index fac64a4..0c9bd4a 100644 |
361 | --- a/src/storage_fs.rs |
362 | +++ b/src/storage_fs.rs |
363 | @@ -1,14 +1,18 @@ |
364 | use std::{ |
365 | + collections::HashSet, |
366 | io::Cursor, |
367 | path::{Path, PathBuf}, |
368 | + str::FromStr, |
369 | }; |
370 | |
371 | use bytes::Bytes; |
372 | use futures::StreamExt; |
373 | use tokio::io::AsyncWriteExt; |
374 | + use walkdir::WalkDir; |
375 | |
376 | use crate::{ |
377 | - address::Address, |
378 | + Namespace, |
379 | + address::{Address, Kind}, |
380 | storage::{Error, InnerStream, StorageIface}, |
381 | }; |
382 | |
383 | @@ -32,7 +36,7 @@ impl FileSystem { |
384 | |
385 | #[async_trait::async_trait] |
386 | impl StorageIface for FileSystem { |
387 | - async fn exists<'a>(&self, addr: &Address) -> Result<bool, Error> { |
388 | + async fn exists(&self, addr: &Address) -> Result<bool, Error> { |
389 | let path = addr.path(&self.base); |
390 | if tokio::fs::try_exists(&path) |
391 | .await |
392 | @@ -45,7 +49,7 @@ impl StorageIface for FileSystem { |
393 | } |
394 | } |
395 | |
396 | - async fn write_all<'a>(&self, addr: &Address, bytes: &[u8]) -> Result<(), Error> { |
397 | + async fn write_all(&self, addr: &Address, bytes: &[u8]) -> Result<(), Error> { |
398 | let path = addr.path(&self.base); |
399 | self.ensure_dir(&path).await?; |
400 | let mut fp = tokio::fs::OpenOptions::new() |
401 | @@ -61,7 +65,7 @@ impl StorageIface for FileSystem { |
402 | Ok(()) |
403 | } |
404 | |
405 | - async fn write<'a>(&self, addr: &Address, bytes: &[u8]) -> Result<(), Error> { |
406 | + async fn write(&self, addr: &Address, bytes: &[u8]) -> Result<(), Error> { |
407 | let path = addr.path(&self.base); |
408 | let mut fp = tokio::fs::OpenOptions::new() |
409 | .create(false) |
410 | @@ -76,7 +80,7 @@ impl StorageIface for FileSystem { |
411 | Ok(()) |
412 | } |
413 | |
414 | - async fn mv<'a>(&self, src: &Address, dst: &Address) -> Result<(), Error> { |
415 | + async fn mv(&self, src: &Address, dst: &Address) -> Result<(), Error> { |
416 | let src_path = src.path(&self.base); |
417 | let dst_path = dst.path(&self.base); |
418 | self.ensure_dir(&dst_path).await?; |
419 | @@ -85,7 +89,7 @@ impl StorageIface for FileSystem { |
420 | Ok(()) |
421 | } |
422 | |
423 | - async fn read<'a>(&self, src: &Address) -> Result<InnerStream, Error> { |
424 | + async fn read(&self, src: &Address) -> Result<InnerStream, Error> { |
425 | let path = src.path(&self.base); |
426 | let fp = tokio::fs::File::open(path.as_path()) |
427 | .await |
428 | @@ -97,7 +101,7 @@ impl StorageIface for FileSystem { |
429 | Ok(InnerStream::new(stream.boxed())) |
430 | } |
431 | |
432 | - async fn read_bytes<'a>(&self, src: &Address) -> Result<Bytes, Error> { |
433 | + async fn read_bytes(&self, src: &Address) -> Result<Bytes, Error> { |
434 | let path = src.path(&self.base); |
435 | let payload = tokio::fs::read(path.as_path()) |
436 | .await |
437 | @@ -108,7 +112,7 @@ impl StorageIface for FileSystem { |
438 | Ok(Bytes::from(payload)) |
439 | } |
440 | |
441 | - async fn delete<'a>(&self, src: &Address) -> Result<(), Error> { |
442 | + async fn delete(&self, src: &Address) -> Result<(), Error> { |
443 | let path = src.path(&self.base); |
444 | tokio::fs::remove_file(path.as_path()).await?; |
445 | Ok(()) |
446 | @@ -126,4 +130,33 @@ impl StorageIface for FileSystem { |
447 | names.sort(); |
448 | Ok(names) |
449 | } |
450 | + |
451 | + async fn find(&self, start: &Address, filter: Option<Kind>) -> Result<HashSet<Address>, Error> { |
452 | + let base_dir = self.base.clone(); |
453 | + let start_dir = start.path(&self.base); |
454 | + let handle = tokio::spawn(async move { |
455 | + WalkDir::new(start_dir.as_path()) |
456 | + .into_iter() |
457 | + .filter_map(|entry| entry.ok()) |
458 | + .fold(HashSet::new(), |mut accm, entry| { |
459 | + if entry.path().eq(&start_dir) { |
460 | + return accm; |
461 | + }; |
462 | + let addr_path = entry.path().strip_prefix(base_dir.as_path()).unwrap(); |
463 | + let addr_path_str = addr_path.to_str().unwrap(); |
464 | + if let Ok(addr) = Address::from_str(addr_path_str) { |
465 | + if let Some(filter) = filter { |
466 | + if filter == addr.kind() { |
467 | + accm.insert(addr); |
468 | + } |
469 | + } else { |
470 | + accm.insert(addr); |
471 | + } |
472 | + }; |
473 | + accm |
474 | + }) |
475 | + }); |
476 | + let results = handle.await.unwrap(); |
477 | + Ok(results) |
478 | + } |
479 | } |