From 47ef19d76c6401cbfe5a8300553ed20cecd0a79a Mon Sep 17 00:00:00 2001 From: scott Date: Wed, 8 Apr 2026 12:22:07 -0700 Subject: [PATCH] Add Axum backend, Dockerfiles, and K8s deployment manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Axum backend server: health check, transfer matrix cache (base64 BYTEA), route cache (JSONB), CORS, gzip compression, tracing - Postgres schema: transfer_matrices + cached_routes tables with upserts - Dockerfile.frontend: 3-stage (wasm-pack → SvelteKit → nginx:alpine) - Dockerfile.backend: 2-stage (rust build → debian:bookworm-slim) - nginx.conf: SPA fallback, WASM mime type, /api proxy to backend - docker-compose.yml: Postgres + backend for local development - K8s manifests: namespace, frontend/backend deployments with services, ingress routing, health probes, secret-based DATABASE_URL Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile.backend | 14 +++ Dockerfile.frontend | 24 ++++ crates/mass-driver-backend/Cargo.toml | 6 +- crates/mass-driver-backend/src/db.rs | 152 +++++++++++++++++++++++ crates/mass-driver-backend/src/main.rs | 50 +++++++- crates/mass-driver-backend/src/routes.rs | 134 ++++++++++++++++++++ docker-compose.yml | 28 +++++ k8s/backend.yaml | 62 +++++++++ k8s/frontend.yaml | 42 +++++++ k8s/ingress.yaml | 26 ++++ k8s/namespace.yaml | 4 + nginx.conf | 46 +++++++ 12 files changed, 583 insertions(+), 5 deletions(-) create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend create mode 100644 crates/mass-driver-backend/src/db.rs create mode 100644 crates/mass-driver-backend/src/routes.rs create mode 100644 docker-compose.yml create mode 100644 k8s/backend.yaml create mode 100644 k8s/frontend.yaml create mode 100644 k8s/ingress.yaml create mode 100644 k8s/namespace.yaml create mode 100644 nginx.conf diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..4ff5605 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,14 @@ +# Stage 1: Build the backend binary +FROM rust:1.82-slim AS builder +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ +RUN cargo build --release --bin mass-driver-backend + +# Stage 2: Minimal runtime image +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/mass-driver-backend /usr/local/bin/mass-driver-backend +EXPOSE 3001 +CMD ["mass-driver-backend"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..44ca84a --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,24 @@ +# Stage 1: Build WASM package +FROM rust:1.82-slim AS wasm-builder +RUN apt-get update && apt-get install -y curl pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ +RUN wasm-pack build crates/mass-driver-wasm --target web --out-dir pkg + +# Stage 2: Build SvelteKit frontend +FROM node:22-slim AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY --from=wasm-builder /app/crates/mass-driver-wasm/pkg ./node_modules/mass-driver-wasm +COPY frontend/ ./ +RUN npm run build + +# Stage 3: Serve with nginx +FROM nginx:alpine +COPY --from=frontend-builder /app/frontend/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/nginx.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/crates/mass-driver-backend/Cargo.toml b/crates/mass-driver-backend/Cargo.toml index 931336a..0919160 100644 --- a/crates/mass-driver-backend/Cargo.toml +++ b/crates/mass-driver-backend/Cargo.toml @@ -4,13 +4,13 @@ version = "0.1.0" edition = "2021" [dependencies] -orbital-mechanics = { path = "../orbital-mechanics" } -mass-driver-core = { path = "../mass-driver-core" } axum = "0.8" tokio = { version = "1", features = ["full"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono"] } serde = { workspace = true } serde_json = { workspace = true } tower-http = { version = "0.6", features = ["cors", "compression-gzip"] } tracing = "0.1" tracing-subscriber = "0.3" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/mass-driver-backend/src/db.rs b/crates/mass-driver-backend/src/db.rs new file mode 100644 index 0000000..9eea89b --- /dev/null +++ b/crates/mass-driver-backend/src/db.rs @@ -0,0 +1,152 @@ +use sqlx::PgPool; + +pub async fn init_db(pool: &PgPool) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS transfer_matrices ( + config_hash VARCHAR(64) PRIMARY KEY, + station_count INTEGER NOT NULL, + launch_velocity_kms REAL NOT NULL, + matrix_bytes BYTEA NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + accessed_at TIMESTAMPTZ DEFAULT NOW() + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS cached_routes ( + id SERIAL PRIMARY KEY, + config_hash VARCHAR(64) REFERENCES transfer_matrices(config_hash) ON DELETE CASCADE, + from_station INTEGER NOT NULL, + to_station INTEGER NOT NULL, + route_json JSONB NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(config_hash, from_station, to_station) + ); + "#, + ) + .execute(pool) + .await?; + + tracing::info!("Database tables initialized"); + Ok(()) +} + +pub async fn store_matrix( + pool: &PgPool, + config_hash: &str, + station_count: i32, + launch_velocity_kms: f32, + matrix_bytes: &[u8], +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO transfer_matrices (config_hash, station_count, launch_velocity_kms, matrix_bytes) + VALUES ($1, $2, $3, $4) + ON CONFLICT (config_hash) DO UPDATE + SET station_count = EXCLUDED.station_count, + launch_velocity_kms = EXCLUDED.launch_velocity_kms, + matrix_bytes = EXCLUDED.matrix_bytes, + accessed_at = NOW() + "#, + ) + .bind(config_hash) + .bind(station_count) + .bind(launch_velocity_kms) + .bind(matrix_bytes) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_matrix( + pool: &PgPool, + config_hash: &str, +) -> Result, sqlx::Error> { + // Update accessed_at and return the row + let row = sqlx::query_as::<_, MatrixRow>( + r#" + UPDATE transfer_matrices + SET accessed_at = NOW() + WHERE config_hash = $1 + RETURNING config_hash, station_count, launch_velocity_kms, matrix_bytes, created_at, accessed_at + "#, + ) + .bind(config_hash) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn store_route( + pool: &PgPool, + config_hash: &str, + from_station: i32, + to_station: i32, + route_json: &serde_json::Value, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO cached_routes (config_hash, from_station, to_station, route_json) + VALUES ($1, $2, $3, $4) + ON CONFLICT (config_hash, from_station, to_station) DO UPDATE + SET route_json = EXCLUDED.route_json + "#, + ) + .bind(config_hash) + .bind(from_station) + .bind(to_station) + .bind(route_json) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_route( + pool: &PgPool, + config_hash: &str, + from_station: i32, + to_station: i32, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, RouteRow>( + r#" + SELECT id, config_hash, from_station, to_station, route_json, created_at + FROM cached_routes + WHERE config_hash = $1 AND from_station = $2 AND to_station = $3 + "#, + ) + .bind(config_hash) + .bind(from_station) + .bind(to_station) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +#[derive(sqlx::FromRow, serde::Serialize)] +pub struct MatrixRow { + pub config_hash: String, + pub station_count: i32, + pub launch_velocity_kms: f32, + pub matrix_bytes: Vec, + pub created_at: Option>, + pub accessed_at: Option>, +} + +#[derive(sqlx::FromRow, serde::Serialize)] +pub struct RouteRow { + pub id: i32, + pub config_hash: Option, + pub from_station: i32, + pub to_station: i32, + pub route_json: serde_json::Value, + pub created_at: Option>, +} diff --git a/crates/mass-driver-backend/src/main.rs b/crates/mass-driver-backend/src/main.rs index aa3cc8e..f38d728 100644 --- a/crates/mass-driver-backend/src/main.rs +++ b/crates/mass-driver-backend/src/main.rs @@ -1,3 +1,49 @@ -fn main() { - println!("mass-driver backend — not yet implemented"); +mod db; +mod routes; + +use axum::{routing::{get, post}, Router}; +use sqlx::postgres::PgPoolOptions; +use tower_http::{compression::CompressionLayer, cors::CorsLayer}; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + let port = std::env::var("PORT").unwrap_or_else(|_| "3001".to_string()); + + tracing::info!("Connecting to database..."); + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(&database_url) + .await + .expect("Failed to connect to Postgres"); + + db::init_db(&pool).await.expect("Failed to initialize database tables"); + tracing::info!("Database initialized"); + + let app = Router::new() + .route("/api/health", get(routes::health)) + .route("/api/cache/transfer-matrix", post(routes::store_matrix)) + .route( + "/api/cache/transfer-matrix/{config_hash}", + get(routes::get_matrix), + ) + .route("/api/cache/route", post(routes::store_route)) + .route("/api/cache/route", get(routes::get_route)) + .layer(CompressionLayer::new()) + .layer(CorsLayer::permissive()) + .with_state(pool); + + let addr = format!("0.0.0.0:{port}"); + tracing::info!("Listening on {addr}"); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .expect("Failed to bind listener"); + + axum::serve(listener, app) + .await + .expect("Server error"); } diff --git a/crates/mass-driver-backend/src/routes.rs b/crates/mass-driver-backend/src/routes.rs new file mode 100644 index 0000000..82f574c --- /dev/null +++ b/crates/mass-driver-backend/src/routes.rs @@ -0,0 +1,134 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +use crate::db; + +// ---------- Health ---------- + +pub async fn health() -> impl IntoResponse { + Json(serde_json::json!({ "status": "ok" })) +} + +// ---------- Transfer Matrix ---------- + +#[derive(Deserialize)] +pub struct StoreMatrixRequest { + pub config_hash: String, + pub station_count: i32, + pub launch_velocity_kms: f32, + pub matrix_bytes: String, // base64-encoded +} + +#[derive(Serialize)] +pub struct MatrixResponse { + pub config_hash: String, + pub station_count: i32, + pub launch_velocity_kms: f32, + pub matrix_bytes: String, // base64-encoded +} + +pub async fn store_matrix( + State(pool): State, + Json(req): Json, +) -> Result { + let bytes = BASE64 + .decode(&req.matrix_bytes) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64: {e}")))?; + + db::store_matrix( + &pool, + &req.config_hash, + req.station_count, + req.launch_velocity_kms, + &bytes, + ) + .await + .map_err(|e| { + tracing::error!("DB error storing matrix: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + Ok((StatusCode::CREATED, Json(serde_json::json!({ "stored": true })))) +} + +pub async fn get_matrix( + State(pool): State, + Path(config_hash): Path, +) -> Result { + let row = db::get_matrix(&pool, &config_hash).await.map_err(|e| { + tracing::error!("DB error fetching matrix: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + match row { + Some(m) => Ok(Json(MatrixResponse { + config_hash: m.config_hash, + station_count: m.station_count, + launch_velocity_kms: m.launch_velocity_kms, + matrix_bytes: BASE64.encode(&m.matrix_bytes), + }) + .into_response()), + None => Ok(StatusCode::NOT_FOUND.into_response()), + } +} + +// ---------- Cached Route ---------- + +#[derive(Deserialize)] +pub struct StoreRouteRequest { + pub config_hash: String, + pub from_station: i32, + pub to_station: i32, + pub route_json: serde_json::Value, +} + +#[derive(Deserialize)] +pub struct RouteQuery { + pub config_hash: String, + pub from: i32, + pub to: i32, +} + +pub async fn store_route( + State(pool): State, + Json(req): Json, +) -> Result { + db::store_route( + &pool, + &req.config_hash, + req.from_station, + req.to_station, + &req.route_json, + ) + .await + .map_err(|e| { + tracing::error!("DB error storing route: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + Ok((StatusCode::CREATED, Json(serde_json::json!({ "stored": true })))) +} + +pub async fn get_route( + State(pool): State, + Query(q): Query, +) -> Result { + let row = db::get_route(&pool, &q.config_hash, q.from, q.to) + .await + .map_err(|e| { + tracing::error!("DB error fetching route: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + match row { + Some(r) => Ok(Json(r).into_response()), + None => Ok(StatusCode::NOT_FOUND.into_response()), + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd67877 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: massdriver + POSTGRES_PASSWORD: massdriver + POSTGRES_DB: massdriver + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + backend: + build: + context: . + dockerfile: Dockerfile.backend + ports: + - "3001:3001" + environment: + DATABASE_URL: postgres://massdriver:massdriver@postgres:5432/massdriver + PORT: "3001" + depends_on: + - postgres + +volumes: + pgdata: diff --git a/k8s/backend.yaml b/k8s/backend.yaml new file mode 100644 index 0000000..2c32fac --- /dev/null +++ b/k8s/backend.yaml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: mass-driver + labels: + app: backend +spec: + replicas: 2 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: mass-driver-backend:latest + ports: + - containerPort: 3001 + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: backend-secrets + key: database-url + - name: PORT + value: "3001" + resources: + requests: + cpu: 100m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + readinessProbe: + httpGet: + path: /api/health + port: 3001 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 3001 + initialDelaySeconds: 10 + periodSeconds: 30 +--- +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: mass-driver +spec: + selector: + app: backend + ports: + - port: 3001 + targetPort: 3001 + protocol: TCP diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml new file mode 100644 index 0000000..00de9f4 --- /dev/null +++ b/k8s/frontend.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: mass-driver + labels: + app: frontend +spec: + replicas: 2 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: mass-driver-frontend:latest + ports: + - containerPort: 80 + resources: + requests: + cpu: 100m + memory: 64Mi + limits: + cpu: 250m + memory: 128Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: mass-driver +spec: + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 + protocol: TCP diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..8cab746 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mass-driver-ingress + namespace: mass-driver + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: backend + port: + number: 3001 + - path: / + pathType: Prefix + backend: + service: + name: frontend + port: + number: 80 diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..b9f3855 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mass-driver diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3affb6e --- /dev/null +++ b/nginx.conf @@ -0,0 +1,46 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + types { + application/wasm wasm; + } + + sendfile on; + tcp_nopush on; + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/wasm; + + server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|wasm)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Proxy /api/ requests to the backend + location /api/ { + proxy_pass http://backend:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA fallback — serve index.html for all other routes + location / { + try_files $uri $uri/ /index.html; + } + } +}