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