initial implementation of the Nx remote cache API
This commit is contained in:
commit
7df6cb5df0
8 changed files with 2165 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
.env
|
1965
Cargo.lock
generated
Normal file
1965
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "enex-rcache"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.10.2"
|
||||
actix-web-httpauth = "0.8.2"
|
||||
config = "0.15.11"
|
||||
dotenvy = "0.15.7"
|
||||
futures-util = "0.3.31"
|
||||
serde = "1.0.219"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# enex-rcache - remote build cache implementation for Nx
|
||||
|
||||
This is an implementation of the [Nx remote cache OpenAPI specification](https://nx.dev/recipes/running-tasks/self-hosted-caching#open-api-specification).
|
35
src/access.rs
Normal file
35
src/access.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use tracing::debug;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum AccessLevel {
|
||||
Read,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
pub fn get_access_level(config: &AppConfig, token: &str) -> Option<AccessLevel> {
|
||||
if config.read_write_tokens.contains(token) {
|
||||
Some(AccessLevel::ReadWrite)
|
||||
} else if config.read_tokens.contains(token) {
|
||||
Some(AccessLevel::Read)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_access_level(
|
||||
config: &AppConfig,
|
||||
token: &str,
|
||||
requested_access_level: AccessLevel,
|
||||
) -> bool {
|
||||
if let Some(access_level) = get_access_level(&config, token) {
|
||||
match access_level {
|
||||
AccessLevel::ReadWrite => true,
|
||||
AccessLevel::Read => requested_access_level == AccessLevel::Read,
|
||||
}
|
||||
} else {
|
||||
debug!("Missing access token");
|
||||
false
|
||||
}
|
||||
}
|
28
src/config.rs
Normal file
28
src/config.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use config::{Config, Environment};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub cache_dir: String,
|
||||
pub read_tokens: HashSet<String>,
|
||||
pub read_write_tokens: HashSet<String>,
|
||||
}
|
||||
|
||||
pub fn load_config() -> AppConfig {
|
||||
let settings = Config::builder()
|
||||
.add_source(
|
||||
Environment::with_prefix("ENEX")
|
||||
.try_parsing(true)
|
||||
.list_separator(",")
|
||||
.with_list_parse_key("read_tokens")
|
||||
.with_list_parse_key("read_write_tokens"),
|
||||
)
|
||||
.build()
|
||||
.expect("Could not initialize config.");
|
||||
settings
|
||||
.try_deserialize::<AppConfig>()
|
||||
.expect("Could not parse config.")
|
||||
}
|
86
src/handlers.rs
Normal file
86
src/handlers.rs
Normal file
|
@ -0,0 +1,86 @@
|
|||
use std::{io::Write, path::PathBuf};
|
||||
|
||||
use actix_web::{
|
||||
HttpResponse, Responder, get, put,
|
||||
web::{Data, Path, Payload},
|
||||
};
|
||||
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
||||
use futures_util::StreamExt;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
use crate::{
|
||||
access::{AccessLevel, has_access_level},
|
||||
config::AppConfig,
|
||||
};
|
||||
|
||||
fn get_cache_entry_path(config: &AppConfig, hash: &str) -> PathBuf {
|
||||
PathBuf::from(&config.cache_dir).join(hash)
|
||||
}
|
||||
|
||||
#[get("/v1/cache/{hash}")]
|
||||
pub async fn get_cache_item(
|
||||
app_data: Data<AppConfig>,
|
||||
auth: BearerAuth,
|
||||
hash: Path<String>,
|
||||
) -> impl Responder {
|
||||
trace!("Requested cache item {}", hash.as_str());
|
||||
if !has_access_level(&app_data, auth.token(), AccessLevel::Read) {
|
||||
debug!(
|
||||
"Tried to read cache item {} without valid read access token.",
|
||||
hash.as_str()
|
||||
);
|
||||
return HttpResponse::Unauthorized()
|
||||
.content_type("text/plain")
|
||||
.body("Please provide a valid access token with at least read-level access.");
|
||||
}
|
||||
let path = get_cache_entry_path(&app_data, &hash);
|
||||
if !path.exists() {
|
||||
trace!("Cache item not found: {}", hash.as_str());
|
||||
HttpResponse::NotFound().body("The record was not found.")
|
||||
} else {
|
||||
trace!("Returning cache item {}", hash.as_str());
|
||||
HttpResponse::Ok().body(std::fs::read(path).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/v1/cache/{hash}")]
|
||||
pub async fn put_cache_item(
|
||||
app_data: Data<AppConfig>,
|
||||
auth: BearerAuth,
|
||||
hash: Path<String>,
|
||||
mut body: Payload,
|
||||
) -> impl Responder {
|
||||
trace!("Received cache item {}", hash.as_str());
|
||||
if !has_access_level(&app_data, auth.token(), AccessLevel::ReadWrite) {
|
||||
debug!(
|
||||
"Tried to write cache item {} without valid read-write access token.",
|
||||
hash.as_str()
|
||||
);
|
||||
return HttpResponse::Unauthorized()
|
||||
.content_type("text/plain")
|
||||
.body("Please provide a valid access token with read-write access.");
|
||||
}
|
||||
let path = get_cache_entry_path(&app_data, &hash);
|
||||
let file = std::fs::File::create_new(&path);
|
||||
match file {
|
||||
Ok(mut file) => {
|
||||
while let Some(chunk) = body.next().await {
|
||||
match chunk {
|
||||
Ok(chunk) => file.write_all(&chunk).expect("This should actually work"),
|
||||
Err(e) => {
|
||||
error!("Could not write cache item chunk: {}", e);
|
||||
drop(file);
|
||||
std::fs::remove_file(path).unwrap(); // Clean up to make sure the block doesn't get half-written with the wrong content
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!("Created cache item {}", hash.as_str());
|
||||
HttpResponse::Accepted().finish()
|
||||
}
|
||||
Err(_) => {
|
||||
trace!("Tried to overwrite existing cache item {}", hash.as_str());
|
||||
HttpResponse::Conflict().body("Cannot overwrite an existing record.")
|
||||
}
|
||||
}
|
||||
}
|
32
src/main.rs
Normal file
32
src/main.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
use actix_web::{App, HttpServer, web::Data};
|
||||
use dotenvy::dotenv;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
mod access;
|
||||
mod config;
|
||||
mod handlers;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_max_level(Level::TRACE)
|
||||
.finish();
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber).expect("Could not initialize logging.");
|
||||
|
||||
dotenv().expect("Could not load .env file.");
|
||||
|
||||
HttpServer::new(|| {
|
||||
let config = config::load_config();
|
||||
App::new()
|
||||
.app_data(Data::new(config))
|
||||
.wrap(actix_web::middleware::Logger::default())
|
||||
.service(handlers::get_cache_item)
|
||||
.service(handlers::put_cache_item)
|
||||
})
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.bind(("::1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
Loading…
Add table
Reference in a new issue