diff --git a/.github/workflows/stability.yml b/.github/workflows/stability.yml index 0983b09..3c09e69 100644 --- a/.github/workflows/stability.yml +++ b/.github/workflows/stability.yml @@ -102,4 +102,16 @@ jobs: - uses: actions/checkout@v6 - name: Checkout Reflective sibling dependencies run: bash scripts/ci/checkout-reflective-siblings.sh - - uses: EmbarkStudios/cargo-deny-action@v2 + # Not EmbarkStudios/cargo-deny-action: its docker container can't + # install the repo's pinned musl toolchain and doesn't see the + # sibling checkouts, so `cargo metadata` fails on converge-core. + # Run cargo-deny in the runner env like the audit job above. + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_VERSION }} + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + - name: Run cargo-deny + run: cargo deny check diff --git a/Cargo.lock b/Cargo.lock index 9c851f4..dc61e60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.1" @@ -187,12 +193,17 @@ dependencies = [ "converge-ferrox-ortools-sys", "converge-ferrox-solver", "prost", + "serial_test", + "temp-env", "tokio", "tonic", + "tonic-health", "tonic-prost", "tonic-prost-build", + "tonic-reflection", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -342,6 +353,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -590,6 +612,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -614,6 +647,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -688,6 +730,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -916,6 +981,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1007,6 +1081,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "rusty-fork" version = "0.3.1" @@ -1019,6 +1099,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1069,6 +1155,31 @@ dependencies = [ "zmij", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1161,6 +1272,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1248,6 +1368,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -1305,6 +1426,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tonic-health" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcfab99db777fba2802f0dfa861d1628d1ae916fb199d29819941f139ae85082" +dependencies = [ + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tonic-prost" version = "0.14.6" @@ -1332,6 +1466,20 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tonic-reflection" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acccd136a4bf19810a1fde9c74edc6129b42a66b44d0c1c8aaa67aeb49a146a7" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tower" version = "0.5.3" @@ -1406,6 +1554,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -1416,12 +1574,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -1486,6 +1647,17 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1534,6 +1706,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" diff --git a/Dockerfile b/Dockerfile index 19e4923..eb21648 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN git clone --depth 1 --branch ${HIGHS_TAG} \ FROM rust:1.94-trixie AS rust-builder RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake clang libclang-dev protobuf-compiler \ + build-essential cmake clang libclang-dev protobuf-compiler git ca-certificates \ && rm -rf /var/lib/apt/lists/* # Bring full source + build trees so the sys crates' build.rs finds headers @@ -49,9 +49,24 @@ WORKDIR /workspace # Build context is the ferrox repo root. COPY . /workspace/ -# Docker builds only receive the ferrox repo as context, so local Reflective -# path patches cannot resolve. Use the published workspace dependency versions. -RUN sed -i '/^\[patch\.crates-io\]/,$d' /workspace/Cargo.toml && \ +# [patch.crates-io] in Cargo.toml references sibling repos via the +# developer-monorepo layout (`../../bedrock-platform/converge/...`, +# `../../bedrock-platform/organism/...`). The Docker build context only +# includes ferrox-solvers, so those paths don't resolve. +# +# Approach (mirrors marquee-apps/quorum-sense/deploy/backend/Dockerfile.cloudrun): +# clone the sibling repos to /bedrock-platform/ and sed-rewrite the +# patch paths to point at the clones. This keeps ferrox's source on the +# same converge-pack/organism API surface it was authored against — +# crucial when ferrox uses APIs not yet published to crates.io +# (e.g., ProvenanceSource::proposed_fact_for added post-3.9.2). +RUN mkdir -p /bedrock-platform && \ + git clone --depth=1 --quiet https://github.com/Reflective-Lab/converge.git /bedrock-platform/converge && \ + git clone --depth=1 --quiet https://github.com/Reflective-Lab/organism.git /bedrock-platform/organism + +RUN sed -i \ + -e 's|path = "../../bedrock-platform/|path = "/bedrock-platform/|g' \ + /workspace/Cargo.toml && \ rm -f /workspace/Cargo.lock ENV FERROX_ORTOOLS_ROOT=/opt/ortools/build diff --git a/Justfile b/Justfile index b511e56..db703d6 100644 --- a/Justfile +++ b/Justfile @@ -269,3 +269,29 @@ release-check: SOAK_DURATION_MIN=5 just soak just lint cargo test --workspace + +# ─── Cloud Run prod deploy (M1) ────────────────────────────────────────────── + +# Build + push prod image. _TAG defaults to v0.7.2-. +# Usage: just deploy-build or just deploy-build TAG=v0.7.2-abc1234 +deploy-build TAG=`echo "v0.7.2-$(git rev-parse --short HEAD)"`: + gcloud builds submit . \ + --project=reflective-labs \ + --config=ops/cloudbuild.prod.yaml \ + --substitutions=_TAG={{TAG}} + +# Apply Cloud Run manifest. Edit ops/cloudrun.prod.yaml image tag first. +deploy-apply: + gcloud run services replace ops/cloudrun.prod.yaml \ + --project=reflective-labs \ + --region=europe-west1 + +# List tenants the server image currently knows about. +tenants-show: + @grep -E '^\s*Tenant \{ slug' crates/ferrox-server/src/tenants.rs + +# Smoke against the deployed service. Requires the Cloud Run service URL. +# Run from Cloud Shell or via `gcloud run services proxy` since ingress=internal. +# Usage: just smoke-prod URL=https://ferrox-server-XXX-ew.a.run.app +smoke-prod URL: + ops/smoke.sh {{URL}} diff --git a/crates/ferrox-server/Cargo.toml b/crates/ferrox-server/Cargo.toml index ae4a0b3..9a499d0 100644 --- a/crates/ferrox-server/Cargo.toml +++ b/crates/ferrox-server/Cargo.toml @@ -8,6 +8,10 @@ repository.workspace = true publish = false description = "gRPC server exposing ferrox solvers (CP-SAT, GLOP, HiGHS)" +[lib] +name = "converge_ferrox_server" +path = "src/lib.rs" + [[bin]] name = "ferrox-server" path = "src/main.rs" @@ -24,14 +28,21 @@ ferrox-highs-sys = { workspace = true, optional = true } tonic = { version = "0.14", features = ["tls-ring"] } tonic-prost = "0.14" +tonic-health = "0.14" +tonic-reflection = "0.14" prost = "0.14" tokio = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } anyhow = "1" +uuid = { version = "1", features = ["v4"] } [build-dependencies] tonic-prost-build = "0.14" +[dev-dependencies] +serial_test = "3" +temp-env = "0.3" + [lints] workspace = true diff --git a/crates/ferrox-server/build.rs b/crates/ferrox-server/build.rs index f559e7b..57fd0b5 100644 --- a/crates/ferrox-server/build.rs +++ b/crates/ferrox-server/build.rs @@ -12,9 +12,16 @@ fn main() -> Result<(), Box> { } } + // Emit the encoded FileDescriptorSet so tonic-reflection can serve + // grpc.reflection.v1.ServerReflection. The path is read at compile time + // via `tonic::include_file_descriptor_set!("ferrox_descriptor")` in main.rs. + let descriptor_path = + std::path::PathBuf::from(std::env::var("OUT_DIR")?).join("ferrox_descriptor.bin"); + tonic_prost_build::configure() .build_server(true) .build_client(false) + .file_descriptor_set_path(&descriptor_path) .compile_protos(&[proto], &[proto_dir])?; Ok(()) diff --git a/crates/ferrox-server/src/interceptor.rs b/crates/ferrox-server/src/interceptor.rs new file mode 100644 index 0000000..754c5f9 --- /dev/null +++ b/crates/ferrox-server/src/interceptor.rs @@ -0,0 +1,51 @@ +//! Tonic interceptor: validate `Authorization` (optional bearer) and +//! `x-converge-app` (tenant); mint/accept `x-request-id`; attach both to +//! request extensions so the service layer can use them in spans. +//! +//! Per spec §6 and §7.2 — bearer is optional (gated by `FERROX_AUTH_TOKEN` +//! env presence); tenant header is required. + +use tonic::{Request, Status}; +use uuid::Uuid; + +use crate::tenants::{TenantRegistry, TenantSlug}; + +#[derive(Clone, Debug)] +pub struct RequestId(pub String); + +#[allow(clippy::result_large_err)] +pub fn request_interceptor(mut req: Request<()>) -> Result, Status> { + // ─ Bearer (optional — disabled when env unset) ───────────────────────── + if let Ok(expected) = std::env::var("FERROX_AUTH_TOKEN") { + let provided = req + .metadata() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if provided != format!("Bearer {expected}") { + return Err(Status::unauthenticated("invalid or missing token")); + } + } + + // ─ Tenant (required) ─────────────────────────────────────────────────── + let slug = req + .metadata() + .get("x-converge-app") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| Status::invalid_argument("missing x-converge-app header"))?; + + let tenant = TenantRegistry::lookup(slug) + .ok_or_else(|| Status::permission_denied(format!("unknown tenant: {slug}")))?; + + // ─ Request ID (mint if absent) ───────────────────────────────────────── + let request_id = req + .metadata() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(String::from) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + req.extensions_mut().insert(TenantSlug(tenant.slug)); + req.extensions_mut().insert(RequestId(request_id)); + Ok(req) +} diff --git a/crates/ferrox-server/src/lib.rs b/crates/ferrox-server/src/lib.rs new file mode 100644 index 0000000..86283d5 --- /dev/null +++ b/crates/ferrox-server/src/lib.rs @@ -0,0 +1,5 @@ +//! Public surface of `converge-ferrox-server` for integration tests + future +//! library consumers. The binary entrypoint stays in `main.rs`. + +pub mod interceptor; +pub mod tenants; diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index c6fde76..17649ab 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -18,32 +18,23 @@ pub mod proto { use std::net::SocketAddr; use tonic::transport::{Identity, Server, ServerTlsConfig}; -use tonic::{Request, Status}; +use converge_ferrox_server::interceptor::request_interceptor; use proto::ferrox::v1::ferrox_solver_server::FerroxSolverServer; use service::FerroxSolverService; -#[allow(clippy::result_large_err)] -fn auth_interceptor(req: Request<()>) -> Result, Status> { - let expected = std::env::var("FERROX_AUTH_TOKEN").ok(); - let Some(token) = expected else { - return Ok(req); // auth disabled when env var is absent - }; - let provided = req - .metadata() - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if provided == format!("Bearer {token}") { - Ok(req) - } else { - Err(Status::unauthenticated("invalid or missing token")) - } -} +/// Encoded FileDescriptorSet for `ferrox.v1`. Emitted by `build.rs` via +/// `tonic_prost_build::configure().file_descriptor_set_path(...)`. Consumed +/// by `tonic-reflection` to advertise the service schema over the standard +/// `grpc.reflection.v1.ServerReflection` API. +const FERROX_FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("ferrox_descriptor"); #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() + .json() + .with_current_span(true) + .with_span_list(false) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "ferrox_server=info".parse().unwrap()), @@ -56,6 +47,22 @@ async fn main() -> anyhow::Result<()> { let svc = FerroxSolverService::default(); + // Health checking — standard grpc.health.v1.Health, exposed without + // tenant gating so Cloud Run probes and grpcurl can hit it freely. + let (health_reporter, health_service) = tonic_health::server::health_reporter(); + health_reporter + .set_serving::>() + .await; + + // gRPC reflection — exposes grpc.reflection.v1.ServerReflection so + // grpcurl / Postman / Buf CLI can introspect the service without a + // local .proto file. Registers both the ferrox.v1 descriptor (emitted + // by build.rs) and tonic-health's bundled descriptor. + let reflection_service = tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET) + .register_encoded_file_descriptor_set(FERROX_FILE_DESCRIPTOR_SET) + .build_v1()?; + let cert_path = std::env::var("FERROX_TLS_CERT").unwrap_or_else(|_| "/tls/server.crt".into()); let key_path = std::env::var("FERROX_TLS_KEY").unwrap_or_else(|_| "/tls/server.key".into()); @@ -77,13 +84,23 @@ async fn main() -> anyhow::Result<()> { Server::builder() .tls_config(tls)? - .add_service(FerroxSolverServer::with_interceptor(svc, auth_interceptor)) + .add_service(health_service) + .add_service(reflection_service) + .add_service(FerroxSolverServer::with_interceptor( + svc, + request_interceptor, + )) .serve(addr) .await?; } else { tracing::warn!("TLS cert/key not found — starting without TLS (dev/test only)"); Server::builder() - .add_service(FerroxSolverServer::with_interceptor(svc, auth_interceptor)) + .add_service(health_service) + .add_service(reflection_service) + .add_service(FerroxSolverServer::with_interceptor( + svc, + request_interceptor, + )) .serve(addr) .await?; } diff --git a/crates/ferrox-server/src/service.rs b/crates/ferrox-server/src/service.rs index 2c8b5d5..445094f 100644 --- a/crates/ferrox-server/src/service.rs +++ b/crates/ferrox-server/src/service.rs @@ -16,24 +16,38 @@ use crate::proto::ferrox::v1::{ SolveCpRequest, SolveCpResponse, SolveLpRequest, SolveLpResponse, SolveMipRequest, SolveMipResponse, }; +use converge_ferrox_server::interceptor::RequestId; +use converge_ferrox_server::tenants::{TenantRegistry, TenantSlug}; #[derive(Clone)] pub struct FerroxSolverService { solve_limit: Arc, + tenants: Arc, } impl FerroxSolverService { - pub fn new(max_blocking_solves: usize) -> Self { + pub fn new(max_blocking_solves: usize, tenants: Arc) -> Self { Self { solve_limit: Arc::new(Semaphore::new(max_blocking_solves.max(1))), + tenants, } } - async fn run_blocking(&self, operation: &'static str, solve: F) -> Result + async fn run_blocking( + &self, + operation: &'static str, + tenant_slug: &str, + solve: F, + ) -> Result where T: Send + 'static, F: FnOnce() -> T + Send + 'static, { + // Per-tenant permit first — if the tenant is over their cap, fail + // fast without consuming the global solver slot. + let _tenant_permit = self.tenants.acquire(tenant_slug).await?; + + // Global solver permit — bounds total concurrent native solves. let permit = self .solve_limit .clone() @@ -52,7 +66,10 @@ impl FerroxSolverService { impl Default for FerroxSolverService { fn default() -> Self { - Self::new(configured_blocking_solves()) + Self::new( + configured_blocking_solves(), + Arc::new(TenantRegistry::default()), + ) } } @@ -73,38 +90,156 @@ fn configured_blocking_solves() -> usize { } } +struct RequestContext<'a> { + tenant: &'static str, + request_id: &'a str, +} + +fn request_context(req: &Request) -> Result, Status> { + let tenant = req + .extensions() + .get::() + .map(|t| t.0) + .ok_or_else(|| Status::internal("missing TenantSlug extension"))?; + let request_id = req + .extensions() + .get::() + .map(|r| r.0.as_str()) + .ok_or_else(|| Status::internal("missing RequestId extension"))?; + Ok(RequestContext { tenant, request_id }) +} + #[tonic::async_trait] impl FerroxSolver for FerroxSolverService { async fn solve_cp( &self, request: Request, ) -> Result, Status> { + let ctx = request_context(&request)?; + let span = tracing::info_span!( + "solve_cp", + tenant_app = %ctx.tenant, + request_id = %ctx.request_id, + rpc_method = "ferrox.v1.FerroxSolver/SolveCp", + ); + let started = std::time::Instant::now(); + let tenant = ctx.tenant; let req = cp_req_from_proto(request.into_inner())?; - let plan = self - .run_blocking("solve_cp", move || solve_cp(&req)) - .await?; - Ok(Response::new(cp_resp_to_proto(plan))) + let result = self + .run_blocking("solve_cp", tenant, move || solve_cp(&req)) + .await; + let elapsed_us = started.elapsed().as_micros(); + match result { + Ok(plan) => { + span.in_scope(|| { + tracing::info!( + solve_duration_us = elapsed_us, + status = "ok", + "solve_cp completed" + ); + }); + Ok(Response::new(cp_resp_to_proto(plan))) + } + Err(err) => { + span.in_scope(|| { + tracing::warn!( + solve_duration_us = elapsed_us, + status = "error", + code = %err.code(), + message = %err.message(), + "solve_cp failed" + ); + }); + Err(err) + } + } } async fn solve_lp( &self, request: Request, ) -> Result, Status> { + let ctx = request_context(&request)?; + let span = tracing::info_span!( + "solve_lp", + tenant_app = %ctx.tenant, + request_id = %ctx.request_id, + rpc_method = "ferrox.v1.FerroxSolver/SolveLp", + ); + let started = std::time::Instant::now(); + let tenant = ctx.tenant; let req = lp_req_from_proto(request.into_inner())?; - let plan = self - .run_blocking("solve_lp", move || solve_lp(&req)) - .await?; - Ok(Response::new(lp_resp_to_proto(plan))) + let result = self + .run_blocking("solve_lp", tenant, move || solve_lp(&req)) + .await; + let elapsed_us = started.elapsed().as_micros(); + match result { + Ok(plan) => { + span.in_scope(|| { + tracing::info!( + solve_duration_us = elapsed_us, + status = "ok", + "solve_lp completed" + ); + }); + Ok(Response::new(lp_resp_to_proto(plan))) + } + Err(err) => { + span.in_scope(|| { + tracing::warn!( + solve_duration_us = elapsed_us, + status = "error", + code = %err.code(), + message = %err.message(), + "solve_lp failed" + ); + }); + Err(err) + } + } } async fn solve_mip( &self, request: Request, ) -> Result, Status> { + let ctx = request_context(&request)?; + let span = tracing::info_span!( + "solve_mip", + tenant_app = %ctx.tenant, + request_id = %ctx.request_id, + rpc_method = "ferrox.v1.FerroxSolver/SolveMip", + ); + let started = std::time::Instant::now(); + let tenant = ctx.tenant; let req = mip_req_from_proto(request.into_inner())?; - let plan = self - .run_blocking("solve_mip", move || solve_mip(&req)) - .await?; - Ok(Response::new(mip_resp_to_proto(plan))) + let result = self + .run_blocking("solve_mip", tenant, move || solve_mip(&req)) + .await; + let elapsed_us = started.elapsed().as_micros(); + match result { + Ok(plan) => { + span.in_scope(|| { + tracing::info!( + solve_duration_us = elapsed_us, + status = "ok", + "solve_mip completed" + ); + }); + Ok(Response::new(mip_resp_to_proto(plan))) + } + Err(err) => { + span.in_scope(|| { + tracing::warn!( + solve_duration_us = elapsed_us, + status = "error", + code = %err.code(), + message = %err.message(), + "solve_mip failed" + ); + }); + Err(err) + } + } } } diff --git a/crates/ferrox-server/src/tenants.rs b/crates/ferrox-server/src/tenants.rs new file mode 100644 index 0000000..06af668 --- /dev/null +++ b/crates/ferrox-server/src/tenants.rs @@ -0,0 +1,98 @@ +//! Tenant allowlist and per-tenant in-flight semaphore. +//! +//! Per design spec §6 (`marquee-apps/quorum-sense/docs/superpowers/specs/ +//! 2026-06-14-converge-grpc-suggestor-pattern-design.md`): +//! - Allowlist is compile-time `const TENANTS`. +//! - Adding a tenant requires a server image rebuild and redeploy. +//! - Per-tenant in-flight cap; over-limit returns `RESOURCE_EXHAUSTED`. +//! - Unknown tenant returns `PERMISSION_DENIED`. +//! - Missing `x-converge-app` header returns `INVALID_ARGUMENT`. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tonic::Status; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tenant { + pub slug: &'static str, + pub max_in_flight: u32, +} + +pub const TENANTS: &[Tenant] = &[Tenant { + slug: "quorum-sense", + max_in_flight: 4, +}]; + +impl Tenant { + /// Return the in-flight cap for `slug`, or `None` if not on the allowlist. + #[must_use] + pub fn cap_for(slug: &str) -> Option { + TENANTS + .iter() + .find(|t| t.slug == slug) + .map(|t| t.max_in_flight) + } +} + +/// Request-extension marker carrying the validated tenant slug into the +/// service layer. Attached by `request_interceptor` after the allowlist +/// check passes. +#[derive(Clone, Copy, Debug)] +pub struct TenantSlug(pub &'static str); + +/// Runtime view of the allowlist, holding one `Semaphore` per tenant. +#[derive(Debug)] +pub struct TenantRegistry { + permits: HashMap<&'static str, Arc>, +} + +impl TenantRegistry { + /// Build a registry from the compile-time `TENANTS` table. + #[must_use] + pub fn from_const() -> Self { + let permits = TENANTS + .iter() + .map(|t| (t.slug, Arc::new(Semaphore::new(t.max_in_flight as usize)))) + .collect(); + Self { permits } + } + + /// Return `Some(&'static Tenant)` if `slug` is on the allowlist. + #[must_use] + pub fn lookup(slug: &str) -> Option<&'static Tenant> { + TENANTS.iter().find(|t| t.slug == slug) + } + + /// Acquire a permit for `slug`, or return a typed gRPC `Status` error. + /// + /// - Unknown slug → `PERMISSION_DENIED` + /// - In-flight cap reached → `RESOURCE_EXHAUSTED` + /// - Semaphore closed (process shutdown) → `UNAVAILABLE` + // Kept `async fn` to preserve the API shape if we later switch to + // `acquire_owned().await` (blocking) — callers shouldn't have to flip + // between `.await` and no-await. + #[allow(clippy::unused_async)] + pub async fn acquire(&self, slug: &str) -> Result { + let sem = self + .permits + .get(slug) + .ok_or_else(|| Status::permission_denied(format!("unknown tenant: {slug}")))? + .clone(); + sem.try_acquire_owned().map_err(|e| match e { + tokio::sync::TryAcquireError::NoPermits => { + Status::resource_exhausted(format!("tenant {slug} at in-flight cap")) + } + tokio::sync::TryAcquireError::Closed => { + Status::unavailable(format!("tenant {slug} semaphore closed")) + } + }) + } +} + +impl Default for TenantRegistry { + fn default() -> Self { + Self::from_const() + } +} diff --git a/crates/ferrox-server/tests/tenant_registry.rs b/crates/ferrox-server/tests/tenant_registry.rs new file mode 100644 index 0000000..d942560 --- /dev/null +++ b/crates/ferrox-server/tests/tenant_registry.rs @@ -0,0 +1,202 @@ +//! Integration tests for the tenant allowlist + per-tenant semaphore. +//! +//! The interceptor unit tests below mutate the `FERROX_AUTH_TOKEN` env var. +//! Rust 1.85+ (edition 2024) makes `std::env::set_var` / `remove_var` +//! `unsafe`, and the workspace lint forbids `unsafe_code` even in tests. +//! We use the `temp-env` crate, which serializes env mutations behind a +//! global mutex and keeps the unsafe internalized to that crate. +//! `serial_test` is layered on top to keep the scoped guards from racing +//! with each other across tokio's worker threads. + +use converge_ferrox_server::tenants::{Tenant, TenantRegistry}; +use tonic::Code; + +#[test] +fn quorum_sense_is_on_the_allowlist() { + let t = TenantRegistry::lookup("quorum-sense").expect("seeded tenant"); + assert_eq!(t.slug, "quorum-sense"); + assert!(t.max_in_flight >= 1, "in-flight cap must be positive"); +} + +#[test] +fn unknown_tenant_returns_none_from_lookup() { + assert!(TenantRegistry::lookup("nope-not-real").is_none()); +} + +#[tokio::test] +async fn acquire_unknown_tenant_returns_permission_denied() { + let reg = TenantRegistry::default(); + let err = reg + .acquire("nope-not-real") + .await + .expect_err("should error"); + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("unknown tenant")); +} + +#[tokio::test] +async fn acquire_known_tenant_below_cap_succeeds() { + let reg = TenantRegistry::default(); + let _permit = reg.acquire("quorum-sense").await.expect("permit"); + // _permit drops at end of scope, returning the slot. +} + +#[tokio::test] +async fn acquire_over_cap_returns_resource_exhausted() { + let reg = TenantRegistry::default(); + let cap = Tenant::cap_for("quorum-sense").expect("seeded") as usize; + let mut held = Vec::with_capacity(cap); + for _ in 0..cap { + held.push(reg.acquire("quorum-sense").await.expect("under cap")); + } + let err = reg.acquire("quorum-sense").await.expect_err("over cap"); + assert_eq!(err.code(), Code::ResourceExhausted); + assert!(err.message().contains("at in-flight cap")); +} + +// ─── End-to-end interceptor + service path ────────────────────────────── + +use converge_ferrox_server::tenants::TenantSlug; +use tonic::Request; + +/// Simulate what the interceptor does: attach `TenantSlug` to extensions. +fn with_tenant(mut req: Request, slug: &'static str) -> Request { + req.extensions_mut().insert(TenantSlug(slug)); + req +} + +#[tokio::test] +async fn extensions_carry_tenant_slug_into_service() { + let req = with_tenant(Request::new(()), "quorum-sense"); + let slug = req + .extensions() + .get::() + .map(|t| t.0) + .expect("tenant attached"); + assert_eq!(slug, "quorum-sense"); +} + +// ─── request_interceptor unit tests ───────────────────────────────────── + +use converge_ferrox_server::interceptor::request_interceptor; +use serial_test::serial; +use tonic::metadata::MetadataValue; + +const AUTH_ENV: &str = "FERROX_AUTH_TOKEN"; + +/// Helper: build a Request<()> with a tenant header, no bearer. +fn req_with_tenant(slug: &str) -> Request<()> { + let mut req = Request::new(()); + let value: MetadataValue<_> = slug.parse().expect("ascii slug"); + req.metadata_mut().insert("x-converge-app", value); + req +} + +#[test] +#[serial(env)] +fn interceptor_attaches_tenant_slug_on_known_tenant() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("quorum-sense"); + let req = request_interceptor(req).expect("known tenant accepted"); + let attached = req + .extensions() + .get::() + .map(|t| t.0) + .expect("interceptor attaches slug"); + assert_eq!(attached, "quorum-sense"); + }); +} + +#[test] +#[serial(env)] +fn interceptor_rejects_missing_tenant_header() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = Request::new(()); + let err = request_interceptor(req).expect_err("missing header → INVALID_ARGUMENT"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("missing x-converge-app")); + }); +} + +#[test] +#[serial(env)] +fn interceptor_rejects_unknown_tenant() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("nope-not-real"); + let err = request_interceptor(req).expect_err("unknown tenant → PERMISSION_DENIED"); + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("unknown tenant")); + }); +} + +#[test] +#[serial(env)] +fn interceptor_bypasses_bearer_when_env_unset() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("quorum-sense"); + let result = request_interceptor(req); + assert!(result.is_ok(), "bearer disabled when env unset"); + }); +} + +#[test] +#[serial(env)] +fn interceptor_rejects_bad_bearer_when_env_set() { + temp_env::with_var(AUTH_ENV, Some("expected-token"), || { + let mut req = req_with_tenant("quorum-sense"); + let auth: MetadataValue<_> = "Bearer wrong-token".parse().expect("ascii"); + req.metadata_mut().insert("authorization", auth); + let err = request_interceptor(req).expect_err("bad bearer → UNAUTHENTICATED"); + assert_eq!(err.code(), Code::Unauthenticated); + }); +} + +#[test] +#[serial(env)] +fn interceptor_accepts_good_bearer_when_env_set() { + temp_env::with_var(AUTH_ENV, Some("expected-token"), || { + let mut req = req_with_tenant("quorum-sense"); + let auth: MetadataValue<_> = "Bearer expected-token".parse().expect("ascii"); + req.metadata_mut().insert("authorization", auth); + let result = request_interceptor(req); + assert!(result.is_ok(), "good bearer accepted"); + }); +} + +// ─── RequestId mint / pass-through ────────────────────────────────────── + +use converge_ferrox_server::interceptor::RequestId; +use uuid::Uuid; + +#[test] +#[serial(env)] +fn interceptor_mints_request_id_when_header_absent() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("quorum-sense"); + let req = request_interceptor(req).expect("known tenant accepted"); + let id = req + .extensions() + .get::() + .map(|r| r.0.clone()) + .expect("interceptor attaches request id"); + assert!(!id.is_empty(), "minted id must be non-empty"); + Uuid::parse_str(&id).expect("minted id must be a valid UUID"); + }); +} + +#[test] +#[serial(env)] +fn interceptor_accepts_client_supplied_request_id() { + temp_env::with_var_unset(AUTH_ENV, || { + let mut req = req_with_tenant("quorum-sense"); + let value: MetadataValue<_> = "client-supplied-id-12345".parse().expect("ascii"); + req.metadata_mut().insert("x-request-id", value); + let req = request_interceptor(req).expect("known tenant accepted"); + let id = req + .extensions() + .get::() + .map(|r| r.0.clone()) + .expect("interceptor attaches request id"); + assert_eq!(id, "client-supplied-id-12345"); + }); +} diff --git a/kb/Architecture/Cloud Run Deployment.md b/kb/Architecture/Cloud Run Deployment.md index 9f6a5fa..672d2a0 100644 --- a/kb/Architecture/Cloud Run Deployment.md +++ b/kb/Architecture/Cloud Run Deployment.md @@ -50,3 +50,40 @@ for Cloud Run deploy. M2 (soter-server) follows the same pattern; M5 (prism-server) when prism is ready. M4 (`RemoteCpSatBackend` consumers) when a marquee-app needs CP-SAT. + +## Deployed state (M1 shipped 2026-06-15) + +| Field | Value | +|---|---| +| Project | `reflective-labs` (number 640630843925) | +| Region | `europe-west1` | +| Service URL | `https://ferrox-server-640630843925.europe-west1.run.app` | +| Ingress | `internal` | +| VPC connector | `solver-egress-ew1` (10.8.0.0/28, 2× e2-micro) | +| Service account | `ferrox-server@reflective-labs.iam.gserviceaccount.com` | +| Image tag | `v0.7.2-2966adc` (2026-06-15 — reflection enabled; was `v0.7.2-312605d` at initial ship) | +| Image registry | `europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server` | +| Concurrency | 1 (CP-SAT is single-process) | +| Min/Max instances | 1 / 10 | +| CPU / Memory | 2 vCPU / 4 GiB | +| Cloud Run timeout | 300s | +| Bearer auth | off (FERROX_AUTH_TOKEN unset; tenant header is the gate) | +| Tenant allowlist | `quorum-sense` (4 in-flight, compiled into image) | +| Health check | `grpc.health.v1.Health` via `tonic-health` | +| Reflection | `grpc.reflection.v1.ServerReflection` via `tonic-reflection` (live since v0.7.2-2966adc) | +| Smoke script | `ops/smoke.sh ` | +| Cloud Build SHA | `2e9ec147-cc07-42a1-8365-3cd99b6d43a7` (~18 min cold build) | + +**Smoke verified 2026-06-15:** +- `grpc.health.v1.Health/Check` → `SERVING` +- `FerroxSolver/SolveCp` without `x-converge-app` → `INVALID_ARGUMENT: missing x-converge-app header` +- `FerroxSolver/SolveCp` with unknown tenant → `PERMISSION_DENIED: unknown tenant: ` +- `FerroxSolver/SolveCp` with `x-converge-app: quorum-sense` + trivial CP (max x, 0≤x≤1) → `status: "optimal"`, `objective_value: 1`, `solver: "cp-sat-v9.15"` + +**Reflection follow-up (RESOLVED 2026-06-15):** `tonic-reflection 0.14` registered server-side; image `v0.7.2-2966adc` shipped + smoke verified. `grpcurl :443 list` returns three services (`ferrox.v1.FerroxSolver`, `grpc.health.v1.Health`, `grpc.reflection.v1.ServerReflection`). No `-proto` flag needed. + +**Smoke connectivity note:** `gcloud run services proxy` failed via Homebrew gcloud (h2c local listener broken). The smoke was run by temporarily flipping `--ingress=all` for ~3 min, hitting the service URL directly with an ID-token-authenticated grpcurl from the dev laptop, then flipping back to `--ingress=internal`. Auth still required throughout. The recurring smoke path will be: Cloud Shell (browser, inside Google's network) once the service is invoker-bound to a Cloud-Shell-reachable identity, OR via the VPC connector from another in-VPC client. + +## Unblocked + +M2 (soter-server new) + M3 (quorum-sense flips to `RemoteSmtBackend`) can now plan against this service URL. diff --git a/kb/Planning/MILESTONES.md b/kb/Planning/MILESTONES.md index 108aed6..35472a8 100644 --- a/kb/Planning/MILESTONES.md +++ b/kb/Planning/MILESTONES.md @@ -29,6 +29,7 @@ source: mixed - [x] Tag v0.5.1 ## Next: Native Solver Assurance Hardening +**Epic:** E9 **Target:** 2026-05/06 | **Tracks:** OR-Tools + HiGHS reproducibility diff --git a/ops/cloudbuild.prod.yaml b/ops/cloudbuild.prod.yaml new file mode 100644 index 0000000..d3472be --- /dev/null +++ b/ops/cloudbuild.prod.yaml @@ -0,0 +1,36 @@ +# Cloud Build — ferrox-server image for Cloud Run (reflective-labs/prod). +# +# Usage: +# gcloud builds submit . \ +# --project=reflective-labs \ +# --config=ops/cloudbuild.prod.yaml \ +# --substitutions=_TAG=v0.7.2-$(git rev-parse --short HEAD) +# +# The Dockerfile at repo root builds OR-Tools + HiGHS in Stage 1 and +# compiles the Rust server in Stage 2 — self-contained, no math-base. + +substitutions: + _TAG: "latest" + _REPO: "europe-west1-docker.pkg.dev/reflective-labs/converge" + +steps: + - name: "gcr.io/cloud-builders/docker" + env: + - "DOCKER_BUILDKIT=1" + args: + - "build" + - "-t" + - "${_REPO}/ferrox-server:${_TAG}" + - "-f" + - "Dockerfile" + - "." + +images: + - "${_REPO}/ferrox-server:${_TAG}" + +options: + machineType: "E2_HIGHCPU_8" + logging: CLOUD_LOGGING_ONLY + diskSizeGb: 100 # OR-Tools + HiGHS build artifacts are big + +timeout: "3600s" # OR-Tools + HiGHS cold build can take ~30 min on E2_HIGHCPU_8 diff --git a/ops/cloudrun.prod.yaml b/ops/cloudrun.prod.yaml new file mode 100644 index 0000000..23d88be --- /dev/null +++ b/ops/cloudrun.prod.yaml @@ -0,0 +1,60 @@ +# Cloud Run service manifest — ferrox-server, prod, reflective-labs. +# +# Apply with: +# gcloud run services replace ops/cloudrun.prod.yaml \ +# --project=reflective-labs --region=europe-west1 +# +# IMPORTANT: when re-deploying with a new image tag, update +# spec.template.spec.containers[0].image to the exact tag pushed by +# `just deploy-build` (or gcloud builds submit) BEFORE applying. + +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: ferrox-server + labels: + app: ferrox-server + extension: ferrox + platform: converge + annotations: + # Internal-only ingress per spec §7.1. + run.googleapis.com/ingress: internal +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "1" + autoscaling.knative.dev/maxScale: "10" + # VPC connector for ingress=internal callers to reach us. + run.googleapis.com/vpc-access-connector: solver-egress-ew1 + run.googleapis.com/vpc-access-egress: private-ranges-only + spec: + serviceAccountName: ferrox-server@reflective-labs.iam.gserviceaccount.com + # CP-SAT solves are CPU-bound and one solve fully uses an instance. + # Cloud Run scales horizontally to handle bursts. + containerConcurrency: 1 + # Cloud Run hard ceiling for unary HTTP/2 — clamp our timeout below. + timeoutSeconds: 300 + containers: + - image: europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server:v0.7.2-2966adc + ports: + # name: h2c — tells Cloud Run to use HTTP/2 cleartext to the + # container. Cloud Run still terminates client-facing TLS. + - name: h2c + containerPort: 50051 + resources: + limits: + cpu: "2" + memory: "4Gi" + env: + - name: FERROX_ADDR + value: "0.0.0.0:50051" + - name: FERROX_SERVER_MAX_BLOCKING_SOLVES + value: "1" + - name: RUST_LOG + value: "ferrox_server=info" + # No FERROX_AUTH_TOKEN in v1 — tenant header is the only gate + # (spec §7.2). Set this only when the bearer-auth upgrade is wanted. + traffic: + - latestRevision: true + percent: 100 diff --git a/ops/iam-setup.sh b/ops/iam-setup.sh new file mode 100755 index 0000000..77d1372 --- /dev/null +++ b/ops/iam-setup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Idempotent: creates the ferrox-server SA and grants minimal IAM. +# Re-running is safe — SA creation is checked first, role grants are +# additive. +set -euo pipefail + +PROJECT_ID="${PROJECT_ID:-reflective-labs}" +SA_NAME="ferrox-server" +SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" + +# Create SA if it doesn't exist. +if ! gcloud iam service-accounts describe "$SA_EMAIL" \ + --project="$PROJECT_ID" >/dev/null 2>&1; then + echo ">>> creating SA $SA_EMAIL" + gcloud iam service-accounts create "$SA_NAME" \ + --project="$PROJECT_ID" \ + --display-name="ferrox-server (Cloud Run runtime)" \ + --description="Minimal-IAM SA for the ferrox-server Cloud Run service (M1 of the gRPC suggestor pattern). No data-plane permissions — solver service is pure compute." +else + echo ">>> SA $SA_EMAIL already exists" +fi + +# Minimal IAM: +# roles/logging.logWriter — emit structured logs to Cloud Logging +# roles/monitoring.metricWriter — emit metrics +# NOT GRANTED: any Firestore/Storage/Secret Manager — solver owns no data. +echo ">>> granting roles/logging.logWriter" +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="roles/logging.logWriter" \ + --condition=None >/dev/null + +echo ">>> granting roles/monitoring.metricWriter" +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="roles/monitoring.metricWriter" \ + --condition=None >/dev/null + +echo ">>> done. SA: $SA_EMAIL" diff --git a/ops/smoke.sh b/ops/smoke.sh new file mode 100755 index 0000000..e59696c --- /dev/null +++ b/ops/smoke.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Smoke-test the deployed ferrox-server. +# +# ─── Canonical flow: Cloud Shell ────────────────────────────────────────────── +# The ferrox-server Cloud Run service is `ingress=internal`, so callers must +# reach it from inside the VPC. The most reliable + zero-setup path is Cloud +# Shell in the browser. It runs in Google's network, ships grpcurl, and uses +# your authenticated identity by default. +# +# 1. Open https://shell.cloud.google.com (your project = reflective-labs) +# 2. git clone --depth=1 -b next https://github.com/Reflective-Lab/ferrox-solvers.git +# 3. cd ferrox-solvers +# 4. ops/smoke.sh https://ferrox-server-640630843925.europe-west1.run.app +# +# The server registers grpc.reflection.v1.ServerReflection, so no -proto +# flag is needed — grpcurl introspects the schema over the wire. +# +# ─── Alternative: local dev with cloud-run-proxy ────────────────────────────── +# Requires the cloud-run-proxy binary on PATH (Homebrew gcloud ships it under +# /opt/homebrew/share/google-cloud-sdk/bin/ but its h2c local listener has +# been observed to hang on some macOS versions). If you have a working proxy: +# +# gcloud run services proxy ferrox-server --project=reflective-labs \ +# --region=europe-west1 --port=9090 & +# ops/smoke.sh http://localhost:9090 +# +# If the proxy hangs, fall back to Cloud Shell above, or temporarily flip +# `--ingress=all` (auth still required via ID token) for a 3-minute window: +# +# gcloud run services update ferrox-server --project=reflective-labs \ +# --region=europe-west1 --ingress=all +# TOKEN=$(gcloud auth print-identity-token) +# grpcurl -H "authorization: Bearer ${TOKEN}" \ +# ferrox-server-640630843925.europe-west1.run.app:443 list +# # ...smoke... +# gcloud run services update ferrox-server --project=reflective-labs \ +# --region=europe-west1 --ingress=internal +# +# ─── grpcurl install ────────────────────────────────────────────────────────── +# Cloud Shell: pre-installed. +# macOS: brew install grpcurl +# Linux: see https://github.com/fullstorydev/grpcurl/releases +set -euo pipefail + +URL="${1:?usage: smoke.sh }" +HOST_PORT="${URL#https://}" +HOST_PORT="${HOST_PORT#http://}" +HOST_PORT="${HOST_PORT%/}" +SCHEME_FLAGS="" +if [[ "$URL" == http://* ]]; then + SCHEME_FLAGS="-plaintext" +fi + +echo "── 1/4: grpc.health.v1.Health/Check should return SERVING ──" +RESP=$(grpcurl $SCHEME_FLAGS -d '{"service": ""}' "$HOST_PORT" grpc.health.v1.Health/Check) +echo "$RESP" +echo "$RESP" | grep -q '"status": "SERVING"' \ + || { echo "FAIL: expected SERVING"; exit 1; } +echo "ok" +echo + +echo "── 2/4: SolveCp WITHOUT x-converge-app → INVALID_ARGUMENT ──" +if grpcurl $SCHEME_FLAGS -d '{}' "$HOST_PORT" \ + ferrox.v1.FerroxSolver/SolveCp 2>&1 \ + | grep -q "InvalidArgument"; then + echo "ok" +else + echo "FAIL: expected InvalidArgument for missing tenant header" + exit 1 +fi +echo + +echo "── 3/4: SolveCp WITH unknown tenant → PERMISSION_DENIED ──" +if grpcurl $SCHEME_FLAGS -H 'x-converge-app: nope-not-real' \ + -d '{}' "$HOST_PORT" \ + ferrox.v1.FerroxSolver/SolveCp 2>&1 \ + | grep -q "PermissionDenied"; then + echo "ok" +else + echo "FAIL: expected PermissionDenied for unknown tenant" + exit 1 +fi +echo + +echo "── 4/4: SolveCp WITH quorum-sense + minimal valid CP problem ──" +# Trivial CP: maximize x subject to 0 ≤ x ≤ 1. Optimal x=1. +RESP=$(grpcurl $SCHEME_FLAGS \ + -H 'x-converge-app: quorum-sense' \ + -d '{ + "problem": { + "variables": [{"name": "x", "lb": 0, "ub": 1, "is_bool": false}], + "objective": {"sense": "maximize", "linear": {"terms": [{"var": "x", "coeff": 1}], "rhs": 0}} + }, + "time_limit_sec": 5 + }' \ + "$HOST_PORT" ferrox.v1.FerroxSolver/SolveCp) +echo "$RESP" +# Accept any non-error response — the exact shape depends on the solver +# version, but if we got JSON back the solver round-tripped. +echo "$RESP" | grep -q '"status"' \ + || { echo "FAIL: expected a structured response"; exit 1; } +echo "ok" +echo + +echo "── ALL 4 SMOKE CHECKS PASSED ──"