diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d62acd1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/ +.env +.git/ +docs/ +out/ +*.md +!migrations/** diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..abe0869 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +# --- planner: capture the dependency graph for caching --- +FROM rust:1-slim-bookworm AS chef +RUN cargo install cargo-chef --locked +WORKDIR /app + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +# --- builder: compile deps (cached), then the workspace --- +FROM chef AS builder +RUN apt-get update && apt-get install -y --no-install-recommends pkg-config && rm -rf /var/lib/apt/lists/* +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +COPY . . +RUN cargo build --release -p server -p worker + +# --- runtime: distroless + cc, server binary only --- +FROM gcr.io/distroless/cc-debian12 AS runtime +WORKDIR /app +COPY --from=builder /app/target/release/lgtm-backend /usr/local/bin/server +COPY --from=builder /app/target/release/worker /usr/local/bin/worker +COPY migrations /app/migrations +EXPOSE 8080 +# server --role all: run_migrations (advisory-locked) then serve HTTP + worker. +ENTRYPOINT ["/usr/local/bin/server", "--role", "all"] diff --git a/crates/server/src/boot.rs b/crates/server/src/boot.rs new file mode 100644 index 0000000..6853fd7 --- /dev/null +++ b/crates/server/src/boot.rs @@ -0,0 +1,91 @@ +use std::str::FromStr; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + All, + Server, + Worker, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BootStep { + Migrate, + Serve, + Worker, +} + +impl FromStr for Role { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "all" => Ok(Role::All), + "server" => Ok(Role::Server), + "worker" => Ok(Role::Worker), + other => Err(format!("unknown role: {other} (expected all|server|worker)")), + } + } +} + +/// The boot sequence for a role. Migrations always run first (SPEC ยง19); only then +/// does the process serve HTTP and/or start the worker. +pub fn boot_plan(role: Role) -> Vec { + let mut steps = vec![BootStep::Migrate]; + match role { + Role::All => { + steps.push(BootStep::Serve); + steps.push(BootStep::Worker); + } + Role::Server => steps.push(BootStep::Serve), + Role::Worker => steps.push(BootStep::Worker), + } + steps +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_boot_plan_migrates_before_serving() { + let plan = boot_plan(Role::All); + let migrate = plan + .iter() + .position(|s| *s == BootStep::Migrate) + .expect("plan must run migrations"); + let serve = plan + .iter() + .position(|s| *s == BootStep::Serve) + .expect("plan must serve"); + assert!(migrate < serve, "migrations must run before serving"); + } + + #[test] + fn test_boot_plan_role_all_runs_worker_and_server() { + let plan = boot_plan(Role::All); + assert_eq!( + plan, + vec![BootStep::Migrate, BootStep::Serve, BootStep::Worker], + "--role all migrates, serves HTTP, and starts the worker" + ); + } + + #[test] + fn test_boot_plan_role_server_skips_worker() { + let plan = boot_plan(Role::Server); + assert_eq!(plan, vec![BootStep::Migrate, BootStep::Serve]); + } + + #[test] + fn test_boot_plan_role_worker_skips_serving() { + let plan = boot_plan(Role::Worker); + assert_eq!(plan, vec![BootStep::Migrate, BootStep::Worker]); + } + + #[test] + fn test_role_parses_from_str() { + assert_eq!("all".parse::().unwrap(), Role::All); + assert_eq!("server".parse::().unwrap(), Role::Server); + assert_eq!("worker".parse::().unwrap(), Role::Worker); + assert!("nonsense".parse::().is_err()); + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 9557208..a8e089b 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,3 +1,5 @@ +mod boot; + use std::sync::Arc; use anyhow::Context; @@ -5,26 +7,19 @@ use lgtm_core::Config; use server::app::router; use server::state::{AppState, AttestService, DownloadVerifier, JobSink, PublicWriteOut, PublicWriter, SurfaceSet}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Role { - Server, - Worker, - All, -} - -fn parse_role(args: &[String]) -> Role { +fn parse_role(args: &[String]) -> boot::Role { // --role server|worker|all (default all) - let mut role = Role::All; + let mut role = boot::Role::All; let mut it = args.iter(); while let Some(a) = it.next() { if a == "--role" { match it.next().map(String::as_str) { - Some("server") => role = Role::Server, - Some("worker") => role = Role::Worker, - Some("all") | None => role = Role::All, + Some("server") => role = boot::Role::Server, + Some("worker") => role = boot::Role::Worker, + Some("all") | None => role = boot::Role::All, Some(other) => { eprintln!("unknown --role '{other}', defaulting to all"); - role = Role::All; + role = boot::Role::All; } } } @@ -159,7 +154,7 @@ async fn main() -> anyhow::Result<()> { let public_url = config.public_url.clone(); let server_fut = async { - if matches!(role, Role::Server | Role::All) { + if matches!(role, boot::Role::Server | boot::Role::All) { let app = router(state.clone()); let listener = tokio::net::TcpListener::bind(("0.0.0.0", 8080)).await?; tracing::info!(%public_url, "http server listening on :8080"); @@ -168,7 +163,7 @@ async fn main() -> anyhow::Result<()> { Ok::<(), anyhow::Error>(()) }; let worker_fut = async { - if matches!(role, Role::Worker | Role::All) { + if matches!(role, boot::Role::Worker | boot::Role::All) { tracing::info!("apalis worker starting"); // TODO M12: wire the full WorkerState (minter, fulfillment, attester). // For now the worker monitor is a no-op so the server starts without @@ -188,14 +183,14 @@ mod tests { #[test] fn test_parse_role_defaults_to_all() { - assert_eq!(parse_role(&[]), Role::All); + assert_eq!(parse_role(&[]), boot::Role::All); } #[test] fn test_parse_role_server() { assert_eq!( parse_role(&["--role".into(), "server".into()]), - Role::Server + boot::Role::Server ); } @@ -203,12 +198,12 @@ mod tests { fn test_parse_role_worker() { assert_eq!( parse_role(&["--role".into(), "worker".into()]), - Role::Worker + boot::Role::Worker ); } #[test] fn test_parse_role_unknown_defaults_to_all() { - assert_eq!(parse_role(&["--role".into(), "bogus".into()]), Role::All); + assert_eq!(parse_role(&["--role".into(), "bogus".into()]), boot::Role::All); } } diff --git a/docker-compose.yml b/docker-compose.yml index 9aefc25..84a271a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,16 @@ services: + app: + build: . + restart: unless-stopped + env_file: .env + environment: + LGTM_DATABASE_URL: postgres://lgtm:lgtm@postgres:5432/lgtm + depends_on: + postgres: + condition: service_healthy + ports: + - "8080:8080" + postgres: image: postgres:16-alpine restart: unless-stopped