swap backend to rust

This commit is contained in:
2025-07-16 16:43:24 +01:00
parent a674f5641d
commit 7dc46b6ad0
16 changed files with 2191 additions and 42 deletions

View File

@ -1,2 +1,3 @@
.build/
target/
tests/
Justfile

2
backend/.gitignore vendored
View File

@ -1 +1 @@
.build/
target/

1979
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
backend/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "nuchat"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.4"
clap = { version = "4.5.41", features = ["derive"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
tokio = { version = "1.46.1", features = ["full"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
[dev-dependencies]
reqwest = { version = "0.12.22", features = ["json"] }
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "nuchat"
# [lints.clippy]
# missing_errors_doc

View File

@ -1,13 +1,27 @@
FROM golang:1.24-alpine AS build
FROM rust:1.87 AS base
RUN cargo install --locked cargo-chef sccache
ENV RUSTC_WRAPPER=sccache SCCACHE_DIR=/sccache
FROM base AS planner
WORKDIR /app
COPY . .
RUN go build -o nuchat fergus.molloy.xyz/nuchat
RUN cargo chef prepare --recipe-path recipe.json
FROM scratch
FROM base AS builder
WORKDIR /app
COPY --from=planner /app/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=$SCCACHE_DIR,sharing=locked \
cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=$SCCACHE_DIR,sharing=locked \
cargo build --release --bin nuchat --target-dir=/app/target
COPY --from=build /app/nuchat .
FROM gcr.io/distroless/cc AS runtime
WORKDIR /app
COPY --from=builder /app/target/release/nuchat ./nuchat
ENTRYPOINT ["/app/nuchat"]
ENTRYPOINT [ "./nuchat", "--host 0.0.0.0" ]

View File

@ -1,21 +1,18 @@
GO_TEST := "gotestsum --format=testname --"
run *ARGS: build
./target/debug/nuchat {{ARGS}}
dirs:
@mkdir -p .build/tests
start: build
./target/debug/nuchat 2>&1 > target/debug/logs/nuchat.log &
build: dirs
go build -o .build/nuchat fergus.molloy.xyz/nuchat
build:
@mkdir -p target/debug/logs
cargo build
run: build
./.build/nuchat
test: unit integration coverage
test: integration unit
integration:
{{GO_TEST}} ./tests/... | tee .build/tests/integration.log
if [ ! $(curl -sf "localhost:7000/healthcheck" ) ]; then just run; fi
cargo test --test '*'
unit:
{{GO_TEST}} -race $(go list ./... | grep -v "/tests") | tee .build/tests/unit.log
coverage:
{{GO_TEST}} $(go list ./... | grep -v "/tests") -coverprofile .build/tests/coverprofile.txt | tee .build/tests/coverage.log
cargo test --lib --bins

View File

@ -1,3 +0,0 @@
module fergus.molloy.xyz/nuchat
go 1.24.5

View File

@ -1,7 +0,0 @@
package main
import "fmt"
func main() {
fmt.Println("hello world 3")
}

63
backend/src/lib.rs Normal file
View File

@ -0,0 +1,63 @@
mod routes;
use std::sync::mpsc;
use axum::{Router, serve::WithGracefulShutdown};
use tokio::signal;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
pub fn run(
listener: tokio::net::TcpListener,
) -> Result<
WithGracefulShutdown<
tokio::net::TcpListener,
Router,
Router,
impl std::future::Future<Output = ()>,
>,
std::io::Error,
> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// build our application with some routes
let (app, rx) = routes::app();
// run it
tracing::debug!("listening on {}", listener.local_addr()?);
let server = axum::serve(listener, app);
Ok(server.with_graceful_shutdown(shutdown_signal(rx)))
}
async fn shutdown_signal(rx: mpsc::Receiver<bool>) {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
let endpoint = tokio::spawn(async move {
let _ = rx.recv();
});
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
_ = endpoint => {},
}
}

30
backend/src/main.rs Normal file
View File

@ -0,0 +1,30 @@
use std::net::SocketAddr;
use clap::{Parser, command};
use nuchat::run;
use tracing::{Level, event};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Host to bind to
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// Port to use
#[arg(long, default_value_t = 7000)]
port: u16,
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let args = Args::parse();
let str_address = format!("{}:{}", args.host, args.port);
let address: SocketAddr = str_address
.parse()
.unwrap_or_else(|_| panic!("could not parse address: {str_address}"));
let listener = tokio::net::TcpListener::bind(address).await?;
let result = run(listener)?.await;
event!(Level::INFO, "Server stopped");
result
}

View File

@ -0,0 +1,6 @@
use axum::Json;
use serde_json::{Value, json};
pub async fn healthcheck() -> Json<Value> {
Json(json!({"healthy": true}))
}

19
backend/src/routes/mod.rs Normal file
View File

@ -0,0 +1,19 @@
use axum::Router;
use axum::routing::{get, post};
use std::sync::mpsc;
mod healthcheck;
mod shutdown;
use healthcheck::healthcheck;
use shutdown::shutdown;
pub fn app() -> (Router, mpsc::Receiver<bool>) {
let (tx, rx) = mpsc::channel();
(
Router::new()
.route("/healthcheck", get(healthcheck))
.route("/shutdown", post(move || shutdown(tx.clone()))),
rx,
)
}

View File

@ -0,0 +1,8 @@
use std::sync::mpsc;
use tracing::{Level, event};
#[allow(clippy::unused_async)]
pub async fn shutdown(tx: mpsc::Sender<bool>) {
event!(Level::INFO, "Shutdown request received, stopping server...");
tx.send(true).expect("failed to send shutdown signal");
}

View File

@ -0,0 +1,21 @@
use reqwest::StatusCode;
#[tokio::test]
async fn test_healthcheck() -> reqwest::Result<()> {
let response = reqwest::get("http://localhost:7000/healthcheck").await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await?, r#"{"healthy":true}"#);
Ok(())
}
#[tokio::test]
async fn test_healthcheck2() -> reqwest::Result<()> {
let response = reqwest::get("http://localhost:7000/healthcheck").await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await?, r#"{"healthy":true}"#);
Ok(())
}

View File

@ -1,7 +0,0 @@
package tests
import "testing"
func TestHello(t *testing.T) {
}

View File

@ -2,13 +2,14 @@ services:
ui:
build: ./ui
ports:
- "5173:5173"
- "3000:3000"
backend:
build: ./backend
#ports:
#- "7000:7000"
ports:
- "7000:7000"
db:
image: postgres:17-alpine
restart: unless-stopped
ports:
- "5432:5432"
environment: