Commit

Author:

Hash:

Timestamp:

+344 -139 +/-13 browse

Kevin Schoon [me@kevinschoon.com]

0b4403d8386006dc18aede446e6656ba8c207b5e

Fri, 28 Mar 2025 21:01:39 +0000 (2 months ago)

flatten project out, begin fleshing out routes
1diff --git a/Cargo.lock b/Cargo.lock
2index 8d37510..8f700ae 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -27,6 +27,17 @@ dependencies = [
6 ]
7
8 [[package]]
9+ name = "async-trait"
10+ version = "0.1.88"
11+ source = "registry+https://github.com/rust-lang/crates.io-index"
12+ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
13+ dependencies = [
14+ "proc-macro2",
15+ "quote",
16+ "syn",
17+ ]
18+
19+ [[package]]
20 name = "axum"
21 version = "0.8.1"
22 source = "registry+https://github.com/rust-lang/crates.io-index"
23 @@ -420,18 +431,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
24 checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
25
26 [[package]]
27- name = "papyri-axum"
28+ name = "papyri"
29 version = "0.1.0"
30 dependencies = [
31+ "async-trait",
32 "axum",
33 "oci-spec",
34+ "regex",
35+ "serde",
36+ "serde_json",
37+ "thiserror",
38+ "tracing",
39 ]
40
41 [[package]]
42- name = "papyri-common"
43- version = "0.1.0"
44-
45- [[package]]
46 name = "percent-encoding"
47 version = "2.3.1"
48 source = "registry+https://github.com/rust-lang/crates.io-index"
49 @@ -730,10 +743,22 @@ checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
50 dependencies = [
51 "log",
52 "pin-project-lite",
53+ "tracing-attributes",
54 "tracing-core",
55 ]
56
57 [[package]]
58+ name = "tracing-attributes"
59+ version = "0.1.28"
60+ source = "registry+https://github.com/rust-lang/crates.io-index"
61+ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
62+ dependencies = [
63+ "proc-macro2",
64+ "quote",
65+ "syn",
66+ ]
67+
68+ [[package]]
69 name = "tracing-core"
70 version = "0.1.33"
71 source = "registry+https://github.com/rust-lang/crates.io-index"
72 diff --git a/Cargo.toml b/Cargo.toml
73index 05a59c6..7a6e130 100644
74--- a/Cargo.toml
75+++ b/Cargo.toml
76 @@ -1,26 +1,14 @@
77- [workspace]
78- resolver = "2"
79- members = [
80- "papyri-common",
81- "papyri-axum",
82- ]
83+ [package]
84+ name = "papyri"
85+ version = "0.1.0"
86+ edition = "2024"
87
88- # [workspace.dependencies]
89- # async-trait = "0.1.86"
90- # bytes = "1.10.0"
91- # clap = { version = "4.5.20", features = ["derive"] }
92- # clap_complete = { version = "4.4.10" }
93- # serde = { version = "1.0", features = ["derive"] }
94- # git2 = "0.19.0"
95- # rand = "0.8.5"
96- # thiserror = "2.0.11"
97- # tracing = { version = "0.1.41", features=["log"] }
98- # toml = "0.8.20"
99- # futures = "0.3.31"
100- # futures-util = "0.3.31"
101- # sqlx = { version = "0.8.3", features = [ "runtime-tokio-rustls", "sqlite", "macros", "time" ] }
102- # tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
103- #
104- # tokio = { version = "1.43.0", features = ["full"] }
105- # tokio-util = { version = "0.7.13", features = ["io", "compat"] }
106- # tokio-stream = "0.1.17"
107+ [dependencies]
108+ axum = "0.8.1"
109+ oci-spec = "0.7.1"
110+ serde = "1.0.219"
111+ serde_json = "1.0.140"
112+ async-trait = "0.1.88"
113+ regex = "1.11.1"
114+ tracing = { version = "0.1.41", features = ["log"] }
115+ thiserror = "2.0.12"
116 diff --git a/papyri-axum/Cargo.toml b/papyri-axum/Cargo.toml
117deleted file mode 100644
118index 4b83733..0000000
119--- a/papyri-axum/Cargo.toml
120+++ /dev/null
121 @@ -1,8 +0,0 @@
122- [package]
123- name = "papyri-axum"
124- version = "0.1.0"
125- edition = "2024"
126-
127- [dependencies]
128- axum = "0.8.1"
129- oci-spec = "0.7.1"
130 diff --git a/papyri-axum/src/lib.rs b/papyri-axum/src/lib.rs
131deleted file mode 100644
132index 6a664ab..0000000
133--- a/papyri-axum/src/lib.rs
134+++ /dev/null
135 @@ -1 +0,0 @@
136- pub mod routes;
137 diff --git a/papyri-axum/src/routes.rs b/papyri-axum/src/routes.rs
138deleted file mode 100644
139index e5509a8..0000000
140--- a/papyri-axum/src/routes.rs
141+++ /dev/null
142 @@ -1,91 +0,0 @@
143- use axum::Json;
144- use axum::extract::{MatchedPath, Request};
145- use axum::{Router, routing::get};
146- use oci_spec::image::{ImageManifest, ImageIndexBuilder};
147- use serde_json::json;
148- use tower_http::trace::TraceLayer;
149- use tracing::info_span;
150- use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
151-
152- pub async fn handler_index(req: Request) -> Json<serde_json::Value> {
153- req.headers().iter().for_each(|h| {
154- let header_value = h.1.to_str().ok();
155- tracing::info!(key = h.0.as_str(), value = header_value);
156- });
157- Json(json!({}))
158- }
159-
160- pub async fn handler_manifest_index() -> Json<serde_json::Value> {
161- }
162-
163- // async fn serve() {
164- // let app = Router::new()
165- // .route("/", get(|| async { "Hello, World!" }))
166- // .route("/v2", get(handler_index))
167- // .route("/v2/", get(handler_index))
168- // .layer(
169- // TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
170- // // Log the matched route's path (with placeholders not filled in).
171- // // Use request.uri() or OriginalUri if you want the real path.
172- // let matched_path = request
173- // .extensions()
174- // .get::<MatchedPath>()
175- // .map(MatchedPath::as_str);
176- //
177- // let actual_path = request.uri().path();
178- //
179- // info_span!(
180- // "http_request",
181- // method = ?request.method(),
182- // matched_path,
183- // actual_path,
184- // some_other_field = tracing::field::Empty,
185- // )
186- // }), // .on_request(|_request: &Request<_>, _span: &Span| {
187- // // // You can use `_span.record("some_other_field", value)` in one of these
188- // // // closures to attach a value to the initially empty field in the info_span
189- // // // created above.
190- // // })
191- // // .on_response(|_response: &Response, _latency: Duration, _span: &Span| {
192- // // // ...
193- // // })
194- // // .on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {
195- // // // ...
196- // // })
197- // // .on_eos(
198- // // |_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {
199- // // // ...
200- // // },
201- // // )
202- // // .on_failure(
203- // // |_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {
204- // // // ...
205- // // },
206- // // ),
207- // );
208- //
209- // // run our app with hyper, listening globally on port 3000
210- // tracing::info!("Listening @ 0.0.0.0:3000");
211- // let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
212- // axum::serve(listener, app).await.unwrap();
213- // }
214- //
215- // #[tokio::main]
216- // async fn main() {
217- // tracing_subscriber::registry()
218- // .with(
219- // tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
220- // // axum logs rejections from built-in extractors with the `axum::rejection`
221- // // target, at `TRACE` level. `axum::rejection=trace` enables showing those events
222- // format!(
223- // "{}=debug,tower_http=debug,axum::rejection=trace",
224- // env!("CARGO_CRATE_NAME")
225- // )
226- // .into()
227- // }),
228- // )
229- // .with(tracing_subscriber::fmt::layer())
230- // .init();
231- // serve().await;
232- // }
233-
234 diff --git a/papyri-axum/src/storage.rs b/papyri-axum/src/storage.rs
235deleted file mode 100644
236index e69de29..0000000
237--- a/papyri-axum/src/storage.rs
238+++ /dev/null
239 diff --git a/papyri-common/Cargo.toml b/papyri-common/Cargo.toml
240deleted file mode 100644
241index 6e65a6c..0000000
242--- a/papyri-common/Cargo.toml
243+++ /dev/null
244 @@ -1,6 +0,0 @@
245- [package]
246- name = "papyri-common"
247- version = "0.1.0"
248- edition = "2024"
249-
250- [dependencies]
251 diff --git a/papyri-common/src/main.rs b/papyri-common/src/main.rs
252deleted file mode 100644
253index e7a11a9..0000000
254--- a/papyri-common/src/main.rs
255+++ /dev/null
256 @@ -1,3 +0,0 @@
257- fn main() {
258- println!("Hello, world!");
259- }
260 diff --git a/src/error.rs b/src/error.rs
261new file mode 100644
262index 0000000..45f1daa
263--- /dev/null
264+++ b/src/error.rs
265 @@ -0,0 +1,98 @@
266+ use axum::{Json, response::IntoResponse};
267+
268+ use crate::storage::Error as StorageError;
269+
270+ // | ID | Code | Description |
271+ // |-------- | ------------------------|------------------------------------------------------------|
272+ // | code-1 | `BLOB_UNKNOWN` | blob unknown to registry |
273+ // | code-2 | `BLOB_UPLOAD_INVALID` | blob upload invalid |
274+ // | code-3 | `BLOB_UPLOAD_UNKNOWN` | blob upload unknown to registry |
275+ // | code-4 | `DIGEST_INVALID` | provided digest did not match uploaded content |
276+ // | code-5 | `MANIFEST_BLOB_UNKNOWN` | manifest references a manifest or blob unknown to registry |
277+ // | code-6 | `MANIFEST_INVALID` | manifest invalid |
278+ // | code-7 | `MANIFEST_UNKNOWN` | manifest unknown to registry |
279+ // | code-8 | `NAME_INVALID` | invalid repository name |
280+ // | code-9 | `NAME_UNKNOWN` | repository name not known to registry |
281+ // | code-10 | `SIZE_INVALID` | provided length did not match content length |
282+ // | code-11 | `UNAUTHORIZED` | authentication required |
283+ // | code-12 | `DENIED` | requested access to the resource is denied |
284+ // | code-13 | `UNSUPPORTED` | the operation is unsupported |
285+ // | code-14 | `TOOMANYREQUESTS` | too many requests |
286+ #[derive(Debug)]
287+ pub enum Code {
288+ BlobUnknown,
289+ BlobUploadInvalid,
290+ BlobUploadUnknown,
291+ DigestInvalid,
292+ ManifestBlobUnknown,
293+ ManifestInvalid,
294+ ManifestUnknown,
295+ NameInvalid,
296+ NameUnknown,
297+ SizeInvalid,
298+ Unathorized,
299+ Denied,
300+ Unsupported,
301+ TooManyRequests,
302+ }
303+
304+ #[derive(serde::Serialize, Debug)]
305+ pub struct Message {
306+ pub code: String,
307+ pub message: String,
308+ pub detail: Option<String>,
309+ }
310+
311+ #[derive(thiserror::Error, Debug)]
312+ pub enum Error {
313+ Code(Code),
314+ Storage(StorageError),
315+ }
316+
317+ impl std::fmt::Display for Error {
318+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319+ let msg: Message = self.into();
320+ f.write_str(&format!("{:?}", msg))
321+ }
322+ }
323+
324+ impl From<&Error> for Message {
325+ fn from(val: &Error) -> Self {
326+ match val {
327+ Error::Code(code) => match code {
328+ Code::BlobUnknown => Message {
329+ code: String::from("BLOB_UNKNOWN"),
330+ message: String::from("blob unknown to registry"),
331+ detail: None,
332+ },
333+ Code::BlobUploadInvalid => todo!(),
334+ Code::BlobUploadUnknown => todo!(),
335+ Code::DigestInvalid => todo!(),
336+ Code::ManifestBlobUnknown => todo!(),
337+ Code::ManifestInvalid => todo!(),
338+ Code::ManifestUnknown => todo!(),
339+ Code::NameInvalid => todo!(),
340+ Code::NameUnknown => todo!(),
341+ Code::SizeInvalid => todo!(),
342+ Code::Unathorized => todo!(),
343+ Code::Denied => todo!(),
344+ Code::Unsupported => todo!(),
345+ Code::TooManyRequests => todo!(),
346+ },
347+ Error::Storage(error) => {
348+ match error {
349+ StorageError::Unspecified => todo!(),
350+ StorageError::IO => todo!(),
351+ StorageError::NotFound => todo!(),
352+ }
353+ },
354+ }
355+ }
356+ }
357+
358+ impl IntoResponse for Error {
359+ fn into_response(self) -> axum::response::Response {
360+ let message = Message::from(&self);
361+ Json(message).into_response()
362+ }
363+ }
364 diff --git a/src/lib.rs b/src/lib.rs
365new file mode 100644
366index 0000000..c3e1100
367--- /dev/null
368+++ b/src/lib.rs
369 @@ -0,0 +1,3 @@
370+ pub mod routes;
371+ pub mod error;
372+ pub mod storage;
373 diff --git a/src/routes.rs b/src/routes.rs
374new file mode 100644
375index 0000000..acc33b5
376--- /dev/null
377+++ b/src/routes.rs
378 @@ -0,0 +1,179 @@
379+ use std::cell::OnceCell;
380+ use std::str::FromStr;
381+
382+ use axum::Json;
383+ use axum::extract::{MatchedPath, Path, Request};
384+ use axum::response::IntoResponse;
385+ use axum::routing::{delete, head, patch, post, put};
386+ use axum::{Router, routing::get};
387+ use core::result::Result as StdResult;
388+ use oci_spec::image::{ImageIndexBuilder, ImageManifest};
389+ use regex::Regex;
390+ use serde::Deserialize;
391+ use serde_json::json;
392+ use tracing::info_span;
393+
394+ use crate::error::Error;
395+
396+ pub type Result<T = Json<serde_json::Value>, E = Error> = std::result::Result<T, E>;
397+
398+ const NAME_REGEXP_MATCH: &str =
399+ r"[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*";
400+
401+ #[derive(Deserialize)]
402+ pub struct Namespace(String);
403+
404+ impl Namespace {
405+ pub fn validate(&self) -> Result<(), Error> {
406+ let regexp = Regex::new(NAME_REGEXP_MATCH).unwrap();
407+ if regexp.is_match(&self.0) {
408+ Ok(())
409+ } else {
410+ Err(Error::Code(crate::error::Code::NameInvalid))
411+ }
412+ }
413+ }
414+
415+ // Registry conformance applies to the following workflow categories:
416+ //
417+ // 1. **Pull** - Clients are able to pull from the registry
418+ // 2. **Push** - Clients are able to push to the registry
419+ // 3. **Content Discovery** - Clients are able to list or otherwise query the content stored in the registry
420+ // 4. **Content Management** - Clients are able to control the full life-cycle of the content stored in the registry
421+ //
422+
423+ pub async fn index() -> Result {
424+ Ok(Json(json!({})))
425+ }
426+
427+ pub mod pull {
428+ pub async fn read_manifest() {}
429+ pub async fn read_blob() {}
430+ }
431+
432+ pub mod push {
433+ pub async fn write_manifest() {}
434+ pub async fn write_blob() {}
435+ }
436+
437+ pub mod discovery {
438+ use super::*;
439+
440+ pub async fn read_tags(Path(name): Path<Namespace>) -> Result {
441+ name.validate()?;
442+ todo!()
443+ }
444+
445+ pub async fn read_referrers() {}
446+ }
447+
448+ pub mod management {
449+ pub async fn delete_manifest() {}
450+ pub async fn delete_tag() {}
451+ pub async fn delete_blob() {}
452+ }
453+
454+ pub fn router() -> Router {
455+ Router::new()
456+ .route("/v2", get(index))
457+ .route("/v2/", get(index))
458+ .route("/v2/:name/blobs/:digest", get(pull::read_blob))
459+ .route("/v2/:name/blobs/:digest", head(pull::read_blob))
460+ .route("/v2/:name/manifests/:reference", get(pull::read_manifest))
461+ .route("/v2/:name/manifests/:reference", head(pull::read_manifest))
462+ .route("/v2/:name/blobs/uploads", post(push::write_blob))
463+ .route(
464+ "/v2/:name/blobs/uploads/:reference",
465+ patch(push::write_blob),
466+ )
467+ .route("/v2/:name/manifests/:reference", put(push::write_manifest))
468+ .route("/v2/:name/tags/list", get(discovery::read_tags))
469+ .route(
470+ "/v2/:name/manifests/:reference",
471+ delete(management::delete_manifest),
472+ )
473+ .route("/v2/:name/blobs/:digest", delete(management::delete_blob))
474+ // .route("/v2/:name/blobs/uploads", post())
475+ }
476+
477+ // pub async fn handler_index(req: Request) -> Json<serde_json::Value> {
478+ // req.headers().iter().for_each(|h| {
479+ // let header_value = h.1.to_str().ok();
480+ // tracing::info!(key = h.0.as_str(), value = header_value);
481+ // });
482+ // Json(json!({}))
483+ // }
484+ //
485+ // pub async fn handler_manifest_index() -> Json<serde_json::Value> {
486+ // }
487+
488+ // async fn serve() {
489+ // let app = Router::new()
490+ // .route("/", get(|| async { "Hello, World!" }))
491+ // .route("/v2", get(handler_index))
492+ // .route("/v2/", get(handler_index))
493+ // .layer(
494+ // TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
495+ // // Log the matched route's path (with placeholders not filled in).
496+ // // Use request.uri() or OriginalUri if you want the real path.
497+ // let matched_path = request
498+ // .extensions()
499+ // .get::<MatchedPath>()
500+ // .map(MatchedPath::as_str);
501+ //
502+ // let actual_path = request.uri().path();
503+ //
504+ // info_span!(
505+ // "http_request",
506+ // method = ?request.method(),
507+ // matched_path,
508+ // actual_path,
509+ // some_other_field = tracing::field::Empty,
510+ // )
511+ // }), // .on_request(|_request: &Request<_>, _span: &Span| {
512+ // // // You can use `_span.record("some_other_field", value)` in one of these
513+ // // // closures to attach a value to the initially empty field in the info_span
514+ // // // created above.
515+ // // })
516+ // // .on_response(|_response: &Response, _latency: Duration, _span: &Span| {
517+ // // // ...
518+ // // })
519+ // // .on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {
520+ // // // ...
521+ // // })
522+ // // .on_eos(
523+ // // |_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {
524+ // // // ...
525+ // // },
526+ // // )
527+ // // .on_failure(
528+ // // |_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| {
529+ // // // ...
530+ // // },
531+ // // ),
532+ // );
533+ //
534+ // // run our app with hyper, listening globally on port 3000
535+ // tracing::info!("Listening @ 0.0.0.0:3000");
536+ // let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
537+ // axum::serve(listener, app).await.unwrap();
538+ // }
539+ //
540+ // #[tokio::main]
541+ // async fn main() {
542+ // tracing_subscriber::registry()
543+ // .with(
544+ // tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
545+ // // axum logs rejections from built-in extractors with the `axum::rejection`
546+ // // target, at `TRACE` level. `axum::rejection=trace` enables showing those events
547+ // format!(
548+ // "{}=debug,tower_http=debug,axum::rejection=trace",
549+ // env!("CARGO_CRATE_NAME")
550+ // )
551+ // .into()
552+ // }),
553+ // )
554+ // .with(tracing_subscriber::fmt::layer())
555+ // .init();
556+ // serve().await;
557+ // }
558 diff --git a/src/storage.rs b/src/storage.rs
559new file mode 100644
560index 0000000..65cb4c0
561--- /dev/null
562+++ b/src/storage.rs
563 @@ -0,0 +1,21 @@
564+ #[derive(thiserror::Error, Debug)]
565+ pub enum Error {
566+ Unspecified,
567+ IO,
568+ NotFound,
569+ }
570+
571+ impl std::fmt::Display for Error {
572+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573+ match self {
574+ Error::Unspecified => f.write_str("An unspecified storage error occurred"),
575+ Error::IO => f.write_str("A storage IO error occurred"),
576+ Error::NotFound => f.write_str("Storage resource not found"),
577+ }
578+ }
579+ }
580+
581+ #[async_trait::async_trait]
582+ pub trait Storage: Sync + Send {
583+ async fn read_tags() -> Result<Vec<String>, Error>;
584+ }
585 diff --git a/src/storage_fs.rs b/src/storage_fs.rs
586new file mode 100644
587index 0000000..e69de29
588--- /dev/null
589+++ b/src/storage_fs.rs