Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Everything that doesn't belong in the build context — keeps the
# Docker layer cache stable + avoids shipping local build artifacts.

# Rust build / target dirs anywhere in the workspace.
target/
**/target/

# IDE / editor noise.
.idea/
.vscode/
*.swp
*.swo

# Git internals are needed by some build steps; keep .git but exclude
# its packfiles when they're huge. Cargo doesn't need .git.
.git/
.gitignore
.gitattributes

# Test fixtures + bench data that don't ship.
**/tests/fixtures/
**/benches/data/

# CI configs.
.github/
.claude/

# Container artifacts (don't recursively ship dockerfiles into themselves).
Dockerfile
.dockerignore
docker-compose*.yml

# Editor-local lockfiles.
*.lock.bak

# Logs / temporary files.
*.log
*.tmp
.DS_Store
68 changes: 68 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# syntax=docker/dockerfile:1.6
#
# redmine-rs container image — Railway / Heroku / Cloud Run / Fly ready.
#
# Build: docker build -t redmine-rs .
# Run: docker run --rm -p 3000:3000 -e RUST_LOG=info redmine-rs
# Deploy: the platform sets $PORT (Railway auto-injects it); rm-server's
# resolve_bind_addr() picks it up and binds 0.0.0.0:$PORT so the
# platform's external 443/80 edge can reach the container.
#
# Multi-stage:
# 1. builder — rust:1.95 image, compiles a release binary.
# 2. runtime — debian:bookworm-slim, ships only the binary + CA certs.
#
# Image size target: ~80MB compressed (slim + statically-linked rust binary
# minus runtime deps).

# ── stage 1: builder ───────────────────────────────────────────────────
FROM rust:1.95-bookworm AS builder

WORKDIR /build

# Copy the workspace (intentionally simple — let Cargo own caching; the
# Dockerfile layer cache only matters between rebuilds with no source
# change, and we don't want to outsmart Cargo's incremental store).
COPY . .

# Release build of rm-server (the binary). The workspace contains:
# rm-store, rm-auth, rm-handlers, redmine-canon, and rm-server.
# Only the bin target needs to ship.
RUN cargo build --release -p rm-server --bin rm-server

# ── stage 2: runtime ───────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime

# CA certificates for outbound HTTPS (e.g. OIDC token verification once
# rm-auth lands the IdP integration). Wrap with --no-install-recommends
# so we don't drag in apt suggestions; rm + apt clean keeps the layer tight.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

# Run as non-root — PaaS platforms reject root containers (Railway,
# Cloud Run); also the right default for any deploy. UID 10001 lives
# outside the system-account range; no entry in /etc/passwd is needed
# because the binary doesn't read it.
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app

# Ship only the release binary. No source, no target dir, no tests.
COPY --from=builder /build/target/release/rm-server /usr/local/bin/rm-server

# PaaS-ready default. The platform overrides $PORT at deploy time;
# locally `-e PORT=3000` (or just rely on the rm-server config default
# of 3000 when $PORT is absent) binds the container's published port.
ENV PORT=3000 \
RUST_LOG=info \
RM_SEED=on

EXPOSE 3000

USER app:app

# `exec`-form: PID-1 is the binary itself, so SIGTERM reaches it for
# axum's graceful-shutdown handler (rm-server `shutdown_signal()` listens
# on SIGTERM + Ctrl-C).
CMD ["/usr/local/bin/rm-server"]
81 changes: 79 additions & 2 deletions crates/rm-server/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ pub fn build_router_with(store: Store, auth_cfg: AuthConfig) -> Router {
// ── W* width tracks — keep merge calls alphabetised on the URL
// path so parallel branches don't conflict on this file. ──
.merge(rm_handlers::issues::router(state.clone())) // W1: /issues
.merge(rm_handlers::issues_form::router(state.clone())) // D1: /issues/new + POST /issues
.merge(rm_handlers::news::router(state.clone())) // W6a: /news
.merge(rm_handlers::projects::router(state.clone())) // W2: /projects
.merge(rm_handlers::queries::router(state.clone())) // W8a: /queries
Expand Down Expand Up @@ -114,16 +115,54 @@ pub async fn serve(config: ServerConfig) -> std::io::Result<()> {
let store = Store::open()
.await
.map_err(|e| std::io::Error::other(format!("store open: {e}")))?;
// POC autohydrate: seed the demo corpus on first boot if the store is
// empty AND `RM_SEED` isn't set to a disable value. Idempotent — a
// second boot in the same process is a no-op. See rm_store::seed.
let seeded = rm_store::seed::hydrate_demo_data_on_boot(&store)
.await
.map_err(|e| std::io::Error::other(format!("seed: {e}")))?;
if seeded > 0 {
tracing::info!(rows = seeded, "demo data hydrated on boot");
}
let auth_cfg = AuthConfig::key_from_env().unwrap_or_else(AuthConfig::with_random_key);

let app = build_router_with(store, auth_cfg);
let listener = tokio::net::TcpListener::bind(config.bind).await?;
tracing::info!(bind = %config.bind, "rm-server listening");
// Honour $PORT for PaaS deploys (Railway, Heroku, Cloud Run, Fly route
// their public 443/80 edge to $PORT internally; the container MUST bind
// 0.0.0.0:$PORT or the proxy can't reach it). Fall back to the
// configured host:port for local / non-PaaS deploys.
let addr = resolve_bind_addr(std::env::var("PORT").ok(), config.bind)?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(bind = %addr, "rm-server listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
}

/// Resolve the bind address from `$PORT` (when set) or the configured
/// fallback. PaaS proxies route their public edge to `$PORT` and require
/// the app to bind `0.0.0.0:$PORT`; non-PaaS keeps the configured
/// `host:port`. Whitespace-only `$PORT` values are treated as unset.
///
/// Pure helper — the env read happens at the [`serve`] call site so the
/// parse logic is testable without touching process env (the crate is
/// `#![forbid(unsafe_code)]`; `std::env::set_var` is unsafe in recent
/// Rust). Tests cover set / unset / whitespace / malformed input.
///
/// Mirrors `op-server`'s same-named helper (op-nexgen PR #58 — both ports
/// land the lance-graph `CONSUMER_SCAN_TODO.md §B1` PaaS pattern).
fn resolve_bind_addr(
env_port: Option<String>,
fallback: SocketAddr,
) -> std::io::Result<SocketAddr> {
match env_port.as_deref().map(str::trim) {
Some(p) if !p.is_empty() => format!("0.0.0.0:{p}")
.parse()
.map_err(|e| std::io::Error::other(format!("invalid PORT env var `{p}`: {e}"))),
_ => Ok(fallback),
}
}

/// Wait for SIGINT (Ctrl-C) or SIGTERM. Used by the bin to trigger
/// `axum::serve`'s graceful-shutdown branch.
async fn shutdown_signal() {
Expand Down Expand Up @@ -211,4 +250,42 @@ mod tests {
assert_eq!(cfg.bind.port(), 3000);
assert!(cfg.bind.ip().is_unspecified());
}

// ── PaaS deploy: $PORT bind (mirrors op-server PR #58 §B1) ──────

fn fallback() -> SocketAddr {
ServerConfig::default().bind
}

#[test]
fn resolve_bind_addr_uses_port_env_when_set() {
let addr = resolve_bind_addr(Some("3000".into()), fallback()).unwrap();
assert_eq!(addr, "0.0.0.0:3000".parse().unwrap());
}

#[test]
fn resolve_bind_addr_falls_back_when_port_env_is_unset() {
let addr = resolve_bind_addr(None, fallback()).unwrap();
assert_eq!(addr, fallback());
}

#[test]
fn resolve_bind_addr_treats_empty_or_whitespace_port_as_unset() {
for empty in ["", " ", "\t", " \n "] {
let addr = resolve_bind_addr(Some(empty.into()), fallback()).unwrap();
assert_eq!(addr, fallback(), "{empty:?} should fall back");
}
}

#[test]
fn resolve_bind_addr_rejects_malformed_port_with_diagnostic() {
for bad in ["abc", "70000", "-1", "8080:extra"] {
let err = resolve_bind_addr(Some(bad.into()), fallback()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("invalid PORT") && msg.contains(bad),
"input {bad:?} should yield a diagnostic naming the value; got {msg}",
);
}
}
}
1 change: 1 addition & 0 deletions crates/rm-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ mod query;
mod relation;
mod role;
mod scm;
pub mod seed;
mod store;
mod taxonomy;
mod time_entry;
Expand Down
Loading
Loading