mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 16:10:31 +00:00
feat: initialize NORA artifact registry
Cloud-native multi-protocol artifact registry in Rust. - Docker Registry v2 - Maven (+ proxy) - npm (+ proxy) - Cargo, PyPI - Web UI, Swagger, Prometheus - Local & S3 storage - 32MB Docker image Created by DevITWay https://getnora.io
This commit is contained in:
43
nora-registry/src/registry/cargo_registry.rs
Normal file
43
nora-registry/src/registry/cargo_registry.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/cargo/api/v1/crates/{crate_name}", get(get_metadata))
|
||||
.route(
|
||||
"/cargo/api/v1/crates/{crate_name}/{version}/download",
|
||||
get(download),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(crate_name): Path<String>,
|
||||
) -> Response {
|
||||
let key = format!("cargo/{}/metadata.json", crate_name);
|
||||
match state.storage.get(&key).await {
|
||||
Ok(data) => (StatusCode::OK, data).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((crate_name, version)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let key = format!(
|
||||
"cargo/{}/{}/{}-{}.crate",
|
||||
crate_name, version, crate_name, version
|
||||
);
|
||||
match state.storage.get(&key).await {
|
||||
Ok(data) => (StatusCode::OK, data).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
154
nora-registry/src/registry/docker.rs
Normal file
154
nora-registry/src/registry/docker.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, head, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/v2/", get(check))
|
||||
.route("/v2/{name}/blobs/{digest}", head(check_blob))
|
||||
.route("/v2/{name}/blobs/{digest}", get(download_blob))
|
||||
.route(
|
||||
"/v2/{name}/blobs/uploads/",
|
||||
axum::routing::post(start_upload),
|
||||
)
|
||||
.route("/v2/{name}/blobs/uploads/{uuid}", put(upload_blob))
|
||||
.route("/v2/{name}/manifests/{reference}", get(get_manifest))
|
||||
.route("/v2/{name}/manifests/{reference}", put(put_manifest))
|
||||
.route("/v2/{name}/tags/list", get(list_tags))
|
||||
}
|
||||
|
||||
async fn check() -> (StatusCode, Json<Value>) {
|
||||
(StatusCode::OK, Json(json!({})))
|
||||
}
|
||||
|
||||
async fn check_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, digest)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||
match state.storage.get(&key).await {
|
||||
Ok(data) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_LENGTH, data.len().to_string())],
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, digest)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||
match state.storage.get(&key).await {
|
||||
Ok(data) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/octet-stream")],
|
||||
data,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_upload(Path(name): Path<String>) -> Response {
|
||||
let uuid = uuid::Uuid::new_v4().to_string();
|
||||
let location = format!("/v2/{}/blobs/uploads/{}", name, uuid);
|
||||
(
|
||||
StatusCode::ACCEPTED,
|
||||
[
|
||||
(header::LOCATION, location.clone()),
|
||||
("Docker-Upload-UUID".parse().unwrap(), uuid),
|
||||
],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn upload_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, _uuid)): Path<(String, String)>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let digest = match params.get("digest") {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||
match state.storage.put(&key, &body).await {
|
||||
Ok(()) => {
|
||||
let location = format!("/v2/{}/blobs/{}", name, digest);
|
||||
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_manifest(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, reference)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let key = format!("docker/{}/manifests/{}.json", name, reference);
|
||||
match state.storage.get(&key).await {
|
||||
Ok(data) => (
|
||||
StatusCode::OK,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
)],
|
||||
data,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_manifest(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, reference)): Path<(String, String)>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let key = format!("docker/{}/manifests/{}.json", name, reference);
|
||||
match state.storage.put(&key, &body).await {
|
||||
Ok(()) => {
|
||||
use sha2::Digest;
|
||||
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&body));
|
||||
let location = format!("/v2/{}/manifests/{}", name, reference);
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
[
|
||||
(header::LOCATION, location),
|
||||
("Docker-Content-Digest".parse().unwrap(), digest),
|
||||
],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tags(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> (StatusCode, Json<Value>) {
|
||||
let prefix = format!("docker/{}/manifests/", name);
|
||||
let keys = state.storage.list(&prefix).await;
|
||||
let tags: Vec<String> = keys
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
k.strip_prefix(&prefix)
|
||||
.and_then(|t| t.strip_suffix(".json"))
|
||||
.map(String::from)
|
||||
})
|
||||
.collect();
|
||||
(StatusCode::OK, Json(json!({"name": name, "tags": tags})))
|
||||
}
|
||||
94
nora-registry/src/registry/maven.rs
Normal file
94
nora-registry/src/registry/maven.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, put},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/maven2/{*path}", get(download))
|
||||
.route("/maven2/{*path}", put(upload))
|
||||
}
|
||||
|
||||
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||
let key = format!("maven/{}", path);
|
||||
|
||||
// Try local storage first
|
||||
if let Ok(data) = state.storage.get(&key).await {
|
||||
return with_content_type(&path, data).into_response();
|
||||
}
|
||||
|
||||
// Try proxy servers
|
||||
for proxy_url in &state.config.maven.proxies {
|
||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||
|
||||
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
|
||||
Ok(data) => {
|
||||
// Cache in local storage (fire and forget)
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.clone();
|
||||
let data_clone = data.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
return with_content_type(&path, data.into()).into_response();
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
async fn upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
let key = format!("maven/{}", path);
|
||||
match state.storage.put(&key, &body).await {
|
||||
Ok(()) => StatusCode::CREATED,
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
||||
}
|
||||
|
||||
fn with_content_type(
|
||||
path: &str,
|
||||
data: Bytes,
|
||||
) -> (StatusCode, [(header::HeaderName, &'static str); 1], Bytes) {
|
||||
let content_type = if path.ends_with(".pom") {
|
||||
"application/xml"
|
||||
} else if path.ends_with(".jar") {
|
||||
"application/java-archive"
|
||||
} else if path.ends_with(".xml") {
|
||||
"application/xml"
|
||||
} else if path.ends_with(".sha1") || path.ends_with(".md5") {
|
||||
"text/plain"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
|
||||
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
||||
}
|
||||
11
nora-registry/src/registry/mod.rs
Normal file
11
nora-registry/src/registry/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod cargo_registry;
|
||||
mod docker;
|
||||
mod maven;
|
||||
mod npm;
|
||||
mod pypi;
|
||||
|
||||
pub use cargo_registry::routes as cargo_routes;
|
||||
pub use docker::routes as docker_routes;
|
||||
pub use maven::routes as maven_routes;
|
||||
pub use npm::routes as npm_routes;
|
||||
pub use pypi::routes as pypi_routes;
|
||||
89
nora-registry/src/registry/npm.rs
Normal file
89
nora-registry/src/registry/npm.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/npm/{*path}", get(handle_request))
|
||||
}
|
||||
|
||||
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||
// Determine if this is a tarball request or metadata request
|
||||
let is_tarball = path.contains("/-/");
|
||||
|
||||
let key = if is_tarball {
|
||||
let parts: Vec<&str> = path.split("/-/").collect();
|
||||
if parts.len() == 2 {
|
||||
format!("npm/{}/tarballs/{}", parts[0], parts[1])
|
||||
} else {
|
||||
format!("npm/{}", path)
|
||||
}
|
||||
} else {
|
||||
format!("npm/{}/metadata.json", path)
|
||||
};
|
||||
|
||||
// Try local storage first
|
||||
if let Ok(data) = state.storage.get(&key).await {
|
||||
return with_content_type(is_tarball, data).into_response();
|
||||
}
|
||||
|
||||
// Try proxy if configured
|
||||
if let Some(proxy_url) = &state.config.npm.proxy {
|
||||
let url = if is_tarball {
|
||||
// Tarball URL: https://registry.npmjs.org/package/-/package-version.tgz
|
||||
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
||||
} else {
|
||||
// Metadata URL: https://registry.npmjs.org/package
|
||||
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
||||
};
|
||||
|
||||
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
|
||||
// Cache in local storage (fire and forget)
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.clone();
|
||||
let data_clone = data.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
return with_content_type(is_tarball, data.into()).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
||||
}
|
||||
|
||||
fn with_content_type(
|
||||
is_tarball: bool,
|
||||
data: Bytes,
|
||||
) -> (StatusCode, [(header::HeaderName, &'static str); 1], Bytes) {
|
||||
let content_type = if is_tarball {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
"application/json"
|
||||
};
|
||||
|
||||
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
||||
}
|
||||
35
nora-registry/src/registry/pypi.rs
Normal file
35
nora-registry/src/registry/pypi.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/simple/", get(list_packages))
|
||||
}
|
||||
|
||||
async fn list_packages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let keys = state.storage.list("pypi/").await;
|
||||
let mut packages = std::collections::HashSet::new();
|
||||
|
||||
for key in keys {
|
||||
if let Some(pkg) = key.strip_prefix("pypi/").and_then(|k| k.split('/').next()) {
|
||||
packages.insert(pkg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut html = String::from("<html><body><h1>Simple Index</h1>");
|
||||
let mut pkg_list: Vec<_> = packages.into_iter().collect();
|
||||
pkg_list.sort();
|
||||
|
||||
for pkg in pkg_list {
|
||||
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>", pkg, pkg));
|
||||
}
|
||||
html.push_str("</body></html>");
|
||||
|
||||
(StatusCode::OK, Html(html))
|
||||
}
|
||||
Reference in New Issue
Block a user