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
21 changes: 21 additions & 0 deletions contract/cmmty/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "cmmty"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
redis = { version = "0.24", features = ["tokio-comp", "connection-manager"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
utoipa = { version = "4", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "6", features = ["axum"] }

[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }
5 changes: 5 additions & 0 deletions contract/cmmty/docker/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target/
docker/
.git/
.env
*.md
28 changes: 28 additions & 0 deletions contract/cmmty/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# ── builder ──────────────────────────────────────────────────────────────────
FROM rust:1.78-slim AS builder

WORKDIR /app

# Cache dependencies
COPY Cargo.toml Cargo.lock* ./
RUN mkdir src && echo "fn main(){}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
RUN rm -rf src

COPY src ./src
RUN cargo build --release

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

RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/cmmty /usr/local/bin/cmmty

ENV RUST_LOG=info
ENV PORT=3002

EXPOSE 3002

ENTRYPOINT ["cmmty"]
32 changes: 32 additions & 0 deletions contract/cmmty/docker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Docker — cmmty contract service

## Build

```bash
# from contract/cmmty/
docker build -f docker/Dockerfile -t cmmty .
```

## Run with docker compose

```bash
cd docker
docker compose up
```

The service starts on `http://localhost:3002`.

| Endpoint | Description |
|---|---|
| `GET /health` | Liveness check |
| `GET /cmmty/docs` | Swagger UI |
| `GET /cmmty/openapi.json` | Raw OpenAPI spec |
| `GET /cmmty/events/:hash` | Event stream for a hash |

## Environment variables

| Variable | Default | Description |
|---|---|---|
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis connection string |
| `PORT` | `3002` | Listening port |
| `RUST_LOG` | `info` | Log level |
24 changes: 24 additions & 0 deletions contract/cmmty/docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
services:
cmmty:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "3002:3002"
environment:
REDIS_URL: redis://redis:6379
PORT: "3002"
RUST_LOG: info
depends_on:
redis:
condition: service_healthy

redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
133 changes: 133 additions & 0 deletions contract/cmmty/src/event_store/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use chrono::{DateTime, Utc};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

/// Discriminant for each audit event type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
HashAnchored,
HashRevoked,
VerificationQueried,
}

/// An immutable audit event stored in the event log.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CmmtyEvent {
pub kind: EventKind,
pub hash: String,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
}

impl CmmtyEvent {
pub fn new(kind: EventKind, hash: impl Into<String>) -> Self {
Self {
kind,
hash: hash.into(),
timestamp: Utc::now(),
actor: None,
}
}
}

fn redis_key(hash: &str) -> String {
format!("events:{}", hash)
}

/// Append an event to the Redis sorted set for `hash`.
/// Score = Unix timestamp in milliseconds for ordering.
pub async fn append(conn: &mut redis::aio::ConnectionManager, event: &CmmtyEvent) -> anyhow::Result<()> {
let key = redis_key(&event.hash);
let score = event.timestamp.timestamp_millis() as f64;
let payload = serde_json::to_string(event)?;
conn.zadd::<_, _, _, ()>(&key, payload, score).await?;
Ok(())
}

/// Replay all events for `hash` in chronological order.
pub async fn replay(conn: &mut redis::aio::ConnectionManager, hash: &str) -> anyhow::Result<Vec<CmmtyEvent>> {
let key = redis_key(hash);
let raw: Vec<String> = conn.zrange(&key, 0, -1).await?;
raw.iter()
.map(|s| serde_json::from_str(s).map_err(anyhow::Error::from))
.collect()
}

/// Reconstruct current state: returns the latest event kind for the hash, if any.
pub fn current_state(events: &[CmmtyEvent]) -> Option<&EventKind> {
events.last().map(|e| &e.kind)
}

pub fn router(state: AppState) -> Router {
Router::new()
.route("/cmmty/events/:hash", get(get_events))
.with_state(state)
}

/// Retrieve the raw event stream for a document hash.
#[utoipa::path(
get,
path = "/cmmty/events/{hash}",
params(("hash" = String, Path, description = "Document hash")),
responses(
(status = 200, description = "Event stream", body = Vec<CmmtyEvent>),
(status = 500, description = "Internal error")
)
)]
pub async fn get_events(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<Json<Vec<CmmtyEvent>>, StatusCode> {
let mut conn = (*state.redis).clone();
replay(&mut conn, &hash)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

#[cfg(test)]
mod tests {
use super::*;

fn make_event(kind: EventKind) -> CmmtyEvent {
CmmtyEvent::new(kind, "abc123")
}

#[test]
fn test_current_state_empty() {
assert!(current_state(&[]).is_none());
}

#[test]
fn test_current_state_last_wins() {
let events = vec![
make_event(EventKind::HashAnchored),
make_event(EventKind::HashRevoked),
];
assert_eq!(current_state(&events), Some(&EventKind::HashRevoked));
}

#[test]
fn test_event_serialization_roundtrip() {
let e = make_event(EventKind::VerificationQueried);
let json = serde_json::to_string(&e).unwrap();
let back: CmmtyEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.kind, EventKind::VerificationQueried);
assert_eq!(back.hash, "abc123");
}

#[test]
fn test_redis_key_format() {
assert_eq!(redis_key("deadbeef"), "events:deadbeef");
}
}
48 changes: 48 additions & 0 deletions contract/cmmty/src/graceful_shutdown/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use std::time::Duration;
use tokio::signal;
use tracing::info;

/// Returns a future that resolves when SIGTERM or SIGINT is received.
/// Logs shutdown lifecycle messages and enforces a 30-second drain window.
pub async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
};

#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};

#[cfg(not(unix))]
let terminate = std::future::pending::<()>();

tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}

info!("Shutdown initiated — draining in-flight requests (up to 30s)");
tokio::time::sleep(Duration::from_secs(30)).await;
info!("Shutdown complete");
}

#[cfg(test)]
mod tests {
use super::*;
use tokio::time::{timeout, Duration};

/// Verify the drain future completes within the expected window when
/// triggered immediately (simulated by racing with a short timeout).
#[tokio::test]
async fn test_shutdown_does_not_block_indefinitely() {
// We can't send a real signal in a unit test, so we just verify
// that the drain sleep itself is bounded.
let drain = tokio::time::sleep(Duration::from_secs(30));
// Should complete — we just assert it's a valid future.
let _ = timeout(Duration::from_millis(1), drain).await;
}
}
23 changes: 23 additions & 0 deletions contract/cmmty/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
pub mod event_store;
pub mod graceful_shutdown;
pub mod openapi;

use axum::{routing::get, Router};
use redis::aio::ConnectionManager;
use std::sync::Arc;

#[derive(Clone)]
pub struct AppState {
pub redis: Arc<ConnectionManager>,
}

pub fn app(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.merge(openapi::router())
.merge(event_store::router(state.clone()))
}

async fn health() -> &'static str {
"ok"
}
32 changes: 32 additions & 0 deletions contract/cmmty/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use cmmty::{app, graceful_shutdown, AppState};
use redis::aio::ConnectionManager;
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing::info;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();

let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into());
let client = redis::Client::open(redis_url)?;
let conn = ConnectionManager::new(client).await?;

let state = AppState {
redis: Arc::new(conn),
};

let router = app(state);
let port = std::env::var("PORT").unwrap_or_else(|_| "3002".into());
let addr = format!("0.0.0.0:{}", port);
let listener = TcpListener::bind(&addr).await?;
info!("Listening on {}", addr);

axum::serve(listener, router)
.with_graceful_shutdown(graceful_shutdown::shutdown_signal())
.await?;

Ok(())
}
40 changes: 40 additions & 0 deletions contract/cmmty/src/openapi/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use axum::{response::Html, routing::get, Json, Router};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

#[derive(OpenApi)]
#[openapi(
info(
title = "SMALDA Community Contract Service",
version = "0.1.0",
description = "Verification audit trail and event sourcing API"
),
paths(
crate::openapi::health,
crate::event_store::get_events,
),
components(schemas(
crate::event_store::CmmtyEvent,
crate::event_store::EventKind,
))
)]
pub struct ApiDoc;

pub fn router() -> Router {
Router::new()
.merge(
SwaggerUi::new("/cmmty/docs")
.url("/cmmty/openapi.json", ApiDoc::openapi()),
)
.route("/health", get(health))
}

/// Health check
#[utoipa::path(
get,
path = "/health",
responses((status = 200, description = "Service is healthy", body = str))
)]
pub async fn health() -> &'static str {
"ok"
}
Loading