From 723ddb665e41e372343369d2e8463790284cb94e Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 19 Jan 2025 13:07:08 +0400 Subject: [PATCH 01/15] Factor out build app core logic to lib --- server/Cargo.toml | 4 + server/src/ask.rs | 16 +- server/src/lambda.rs | 87 +++++++++ server/src/lib.rs | 153 +++++++++++++++ server/src/list.rs | 9 +- server/src/main.rs | 442 +------------------------------------------ server/src/new.rs | 7 +- server/src/toggle.rs | 13 +- server/src/utils.rs | 212 +++++++++++++++++++++ server/src/vote.rs | 4 +- 10 files changed, 488 insertions(+), 459 deletions(-) create mode 100644 server/src/lambda.rs create mode 100644 server/src/lib.rs create mode 100644 server/src/utils.rs diff --git a/server/Cargo.toml b/server/Cargo.toml index 55c9da6..f64c8d4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,3 +25,7 @@ tower-service = "0.3" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter"] } ulid = { version = "1.0.0", features = ["serde"] } + +[[bin]] +name = "lambda" +path = "./src/lambda.rs" diff --git a/server/src/ask.rs b/server/src/ask.rs index 6c25f51..41debd2 100644 --- a/server/src/ask.rs +++ b/server/src/ask.rs @@ -1,5 +1,5 @@ use super::{Backend, Local}; -use crate::{to_dynamo_timestamp, QUESTIONS_TTL}; +use crate::{utils, QUESTIONS_TTL}; use aws_sdk_dynamodb::{ error::SdkError, operation::put_item::{PutItemError, PutItemOutput}, @@ -16,7 +16,7 @@ use ulid::Ulid; use tracing::{debug, error, info, trace, warn}; impl Backend { - pub(super) async fn ask( + pub async fn ask( &self, eid: &Ulid, qid: &Ulid, @@ -27,10 +27,10 @@ impl Backend { ("eid", AttributeValue::S(eid.to_string())), ("votes", AttributeValue::N(1.to_string())), ("text", AttributeValue::S(q.body)), - ("when", to_dynamo_timestamp(SystemTime::now())), + ("when", utils::to_dynamo_timestamp(SystemTime::now())), ( "expire", - to_dynamo_timestamp(SystemTime::now() + QUESTIONS_TTL), + utils::to_dynamo_timestamp(SystemTime::now() + QUESTIONS_TTL), ), ("hidden", AttributeValue::Bool(false)), ]; @@ -70,12 +70,12 @@ impl Backend { } #[derive(Deserialize, Debug)] -pub(super) struct Question { - pub(super) body: String, - pub(super) asker: Option, +pub struct Question { + pub body: String, + pub asker: Option, } -pub(super) async fn ask( +pub async fn ask( Path(eid): Path, State(dynamo): State, q: Json, diff --git a/server/src/lambda.rs b/server/src/lambda.rs new file mode 100644 index 0000000..27aab5e --- /dev/null +++ b/server/src/lambda.rs @@ -0,0 +1,87 @@ +use axum::response::IntoResponse; +use http_body_util::BodyExt; +use lambda_http::Error; +use std::{future::Future, pin::Pin}; +use tower::Layer; +use tower_http::trace::TraceLayer; +use tower_service::Service; +use tracing_subscriber::EnvFilter; +use wewerewondering_api::build_app; + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .without_time(/* cloudwatch does that */) + .init(); + + let app = build_app().await; + // To run with AWS Lambda runtime, wrap in our `LambdaLayer` + let app = tower::ServiceBuilder::new() + .layer(TraceLayer::new_for_http()) + .layer(LambdaLayer) + .service(app); + + lambda_http::run(app).await +} + +#[derive(Clone, Copy)] +pub struct LambdaLayer; + +impl Layer for LambdaLayer { + type Service = LambdaService; + + fn layer(&self, inner: S) -> Self::Service { + LambdaService { inner } + } +} + +pub struct LambdaService { + inner: S, +} + +impl Service for LambdaService +where + S: Service>, + S::Response: axum::response::IntoResponse + Send + 'static, + S::Error: std::error::Error + Send + Sync + 'static, + S::Future: Send + 'static, +{ + type Response = lambda_http::Response; + type Error = lambda_http::Error; + type Future = + Pin> + Send + 'static>>; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.poll_ready(cx).map_err(Into::into) + } + + fn call(&mut self, req: lambda_http::Request) -> Self::Future { + let (parts, body) = req.into_parts(); + let body = match body { + lambda_http::Body::Empty => axum::body::Body::default(), + lambda_http::Body::Text(t) => t.into(), + lambda_http::Body::Binary(v) => v.into(), + }; + + let request = axum::http::Request::from_parts(parts, body); + + let fut = self.inner.call(request); + let fut = async move { + let resp = fut.await?; + let (parts, body) = resp.into_response().into_parts(); + let bytes = body.collect().await?.to_bytes(); + let bytes: &[u8] = &bytes; + let resp: hyper::Response = match std::str::from_utf8(bytes) { + Ok(s) => hyper::Response::from_parts(parts, s.into()), + Err(_) => hyper::Response::from_parts(parts, bytes.into()), + }; + Ok(resp) + }; + + Box::pin(fut) + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..c56d0e7 --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,153 @@ +use aws_sdk_dynamodb::types::AttributeValue; +use axum::routing::{get, post}; +use axum::Router; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tower_http::limit::RequestBodyLimitLayer; +use ulid::Ulid; + +mod ask; +mod event; +mod list; +mod new; +mod questions; +mod toggle; +mod utils; +mod vote; + +pub use ask::Question; +pub use utils::to_dynamo_timestamp; +pub use vote::UpDown; + +#[cfg(debug_assertions)] +const SEED: &str = include_str!("test.json"); + +const QUESTIONS_EXPIRE_AFTER_DAYS: u64 = 30; +const QUESTIONS_TTL: Duration = Duration::from_secs(QUESTIONS_EXPIRE_AFTER_DAYS * 24 * 60 * 60); + +const EVENTS_EXPIRE_AFTER_DAYS: u64 = 60; +const EVENTS_TTL: Duration = Duration::from_secs(EVENTS_EXPIRE_AFTER_DAYS * 24 * 60 * 60); + +#[derive(Clone, Debug, Default)] +pub struct Local { + pub events: HashMap, + pub questions: HashMap>, + pub questions_by_eid: HashMap>, +} + +#[derive(Clone, Debug)] +pub enum Backend { + Dynamo(aws_sdk_dynamodb::Client), + Local(Arc>), +} + +impl Backend { + async fn local() -> Self { + Backend::Local(Arc::new(Mutex::new(Local::default()))) + } + + /// Instantiate a DynamoDB backend. + /// + /// If `USE_DYNAMODB` is set to "local", the `AWS_ENDPOINT_URL` will be taken + /// from the environment with the "http://localhost:8000" fallback , the `AWS_DEFAULT_REGION` + /// will be pulled from the environment as well and will default to "us-east-1", + /// as for the credentials - the [test credentials](https://docs.rs/aws-config/latest/aws_config/struct.ConfigLoader.html#method.test_credentials) + /// will be used to sign requests. + /// + /// This spares setting those environment variables (including `AWS_ACCESS_KEY_ID` + /// and `AWS_SECRET_ACCESS_KEY`) via the command line or configuration files, + /// and allows to run the application against a local dynamodb instance with just: + /// ```sh + /// USE_DYNAMODB=local cargo run + /// ``` + /// While the entire test suite can be run with: + /// ```sh + /// USE_DYNAMODB=local cargo t -- --include-ignored + /// ``` + /// + /// This also allows us to use the local instance of DynamoDB which is running + /// in a container on the same network, in which case the database will be accessible + /// under `http://:`. This facilitates the setup of + /// local API Gateway with SAM, since the `sam local start-api` command will launch our + /// back-end app in a docker container. + /// + /// If more customization is needed (say, you want to set some specific credentials + /// rather than rely on those test creds generated by the `aws_config` crate), + /// set `USE_DYNAMODB` to e.g. "custom", and set the environment variables to whatever + /// values you need or let them be picked up from your `~/.aws` files + /// (see [`aws_config::load_from_env`](https://docs.rs/aws-config/latest/aws_config/fn.load_from_env.html)) + pub async fn dynamo() -> Self { + let config = if std::env::var("USE_DYNAMODB") + .ok() + .is_some_and(|v| v == "local") + { + aws_config::from_env() + .endpoint_url( + std::env::var("AWS_ENDPOINT_URL") + .ok() + .unwrap_or("http://localhost:8000".into()), + ) + .region(aws_config::Region::new( + std::env::var("AWS_DEFAULT_REGION") + .ok() + .unwrap_or("us-east-1".into()), + )) + .test_credentials() + .load() + .await + } else { + aws_config::load_from_env().await + }; + Backend::Dynamo(aws_sdk_dynamodb::Client::new(&config)) + } +} + +pub async fn build_app() -> Router { + #[cfg(not(debug_assertions))] + let backend = Backend::dynamo().await; + + #[cfg(debug_assertions)] + let backend = { + use rand::prelude::SliceRandom; + + let mut backend = if std::env::var_os("USE_DYNAMODB").is_some() { + Backend::dynamo().await + } else { + Backend::local().await + }; + + // to aid in development, seed the backend with a test event and related + // questions, and auto-generate user votes over time + let qids = crate::utils::seed(&mut backend).await; + let cheat = backend.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + interval.tick().await; + loop { + interval.tick().await; + let qid = qids + .choose(&mut rand::thread_rng()) + .expect("there _are_ some questions for our test event"); + let _ = cheat.vote(qid, vote::UpDown::Up).await; + } + }); + + backend + }; + + Router::new() + .route("/api/event", post(new::new)) + .route("/api/event/:eid", post(ask::ask)) + .route("/api/event/:eid", get(event::event)) + .route("/api/event/:eid/questions", get(list::list)) + .route("/api/event/:eid/questions/:secret", get(list::list_all)) + .route( + "/api/event/:eid/questions/:secret/:qid/toggle/:property", + post(toggle::toggle), + ) + .route("/api/vote/:qid/:updown", post(vote::vote)) + .route("/api/questions/:qids", get(questions::questions)) + .layer(RequestBodyLimitLayer::new(1024)) + .with_state(backend) +} diff --git a/server/src/list.rs b/server/src/list.rs index d8fbcda..0e11520 100644 --- a/server/src/list.rs +++ b/server/src/list.rs @@ -1,4 +1,5 @@ use super::{Backend, Local}; +use crate::utils; use aws_sdk_dynamodb::{ error::SdkError, operation::query::{QueryError, QueryOutput}, @@ -59,7 +60,7 @@ impl Backend { } = &mut *local; if !events.contains_key(eid) { - return Err(super::mint_service_error( + return Err(utils::mint_service_error( QueryError::ResourceNotFoundException( ResourceNotFoundException::builder().build(), ), @@ -125,7 +126,7 @@ async fn list_inner( ) { let has_secret = if let Some(secret) = secret { debug!("list questions with admin access"); - if let Err(e) = super::check_secret(&dynamo, &eid, &secret).await { + if let Err(e) = utils::check_secret(&dynamo, &eid, &secret).await { // a bad secret will not turn good and // events are unlikely to re-appear with the same Ulid return ( @@ -138,7 +139,7 @@ async fn list_inner( trace!("list questions with guest access"); // ensure that the event exists: // this is _just_ so give 404s for old events so clients stop polling - if let Err(e) = super::get_secret(&dynamo, &eid).await { + if let Err(e) = utils::get_secret(&dynamo, &eid).await { // events are unlikely to re-appear with the same Ulid return ( AppendHeaders([(header::CACHE_CONTROL, "max-age=86400")]), @@ -236,7 +237,7 @@ async fn list_inner( let votes = q["votes"].as_u64().expect("votes is a number") as f64; // max so that even if vote count somehow got to 0, count it as 1 let votes = votes.max(1.); - let exp = (-1. * dt as f64).exp_m1() + 1.; + let exp = (-1. * dt).exp_m1() + 1.; Score(exp * votes / (1. - exp)) }; questions.sort_by_cached_key(|q| std::cmp::Reverse(score(q))); diff --git a/server/src/main.rs b/server/src/main.rs index 51c6d64..cbb1d41 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,444 +1,14 @@ -use aws_sdk_dynamodb::{error::SdkError, types::AttributeValue}; -use aws_smithy_types::body::SdkBody; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::Router; -use http::StatusCode; -use http_body_util::BodyExt; use lambda_http::Error; -use std::time::{Duration, SystemTime}; -use std::{ - collections::HashMap, - future::Future, - pin::Pin, - sync::{Arc, Mutex}, -}; -use tower::Layer; -use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer}; -use tower_service::Service; use tracing_subscriber::EnvFilter; -use ulid::Ulid; - -const QUESTIONS_EXPIRE_AFTER_DAYS: u64 = 30; -const QUESTIONS_TTL: Duration = Duration::from_secs(QUESTIONS_EXPIRE_AFTER_DAYS * 24 * 60 * 60); - -const EVENTS_EXPIRE_AFTER_DAYS: u64 = 60; -const EVENTS_TTL: Duration = Duration::from_secs(EVENTS_EXPIRE_AFTER_DAYS * 24 * 60 * 60); - -#[allow(unused_imports)] -use tracing::{debug, error, info, trace, warn}; - -#[cfg(debug_assertions)] -const SEED: &str = include_str!("test.json"); - -#[derive(Clone, Debug)] -#[allow(dead_code)] -enum Backend { - Dynamo(aws_sdk_dynamodb::Client), - Local(Arc>), -} - -impl Backend { - async fn local() -> Self { - Backend::Local(Arc::new(Mutex::new(Local::default()))) - } - - /// Instantiate a DynamoDB backend. - /// - /// If `USE_DYNAMODB` is set to "local", the `AWS_ENDPOINT_URL` will be taken - /// from the environment with the "http://localhost:8000" fallback , the `AWS_DEFAULT_REGION` - /// will be pulled from the environment as well and will default to "us-east-1", - /// as for the credentials - the [test credentials](https://docs.rs/aws-config/latest/aws_config/struct.ConfigLoader.html#method.test_credentials) - /// will be used to sign requests. - /// - /// This spares setting those environment variables (including `AWS_ACCESS_KEY_ID` - /// and `AWS_SECRET_ACCESS_KEY`) via the command line or configuration files, - /// and allows to run the application against a local dynamodb instance with just: - /// ```sh - /// USE_DYNAMODB=local cargo run - /// ``` - /// While the entire test suite can be run with: - /// ```sh - /// USE_DYNAMODB=local cargo t -- --include-ignored - /// ``` - /// - /// This also allows us to use the local instance of DynamoDB which is running - /// in a container on the same network, in which case the database will be accessible - /// under `http://:`. This facilitates the setup of - /// local API Gateway with SAM, since the `sam local start-api` command will launch our - /// back-end app in a docker container. - /// - /// If more customization is needed (say, you want to set some specific credentials - /// rather than rely on those test creds generated by the `aws_config` crate), - /// set `USE_DYNAMODB` to e.g. "custom", and set the environment variables to whatever - /// values you need or let them be picked up from your `~/.aws` files - /// (see [`aws_config::load_from_env`](https://docs.rs/aws-config/latest/aws_config/fn.load_from_env.html)) - async fn dynamo() -> Self { - let config = if std::env::var("USE_DYNAMODB") - .ok() - .is_some_and(|v| v == "local") - { - aws_config::from_env() - .endpoint_url( - std::env::var("AWS_ENDPOINT_URL") - .ok() - .unwrap_or("http://localhost:8000".into()), - ) - .region(aws_config::Region::new( - std::env::var("AWS_DEFAULT_REGION") - .ok() - .unwrap_or("us-east-1".into()), - )) - .test_credentials() - .load() - .await - } else { - aws_config::load_from_env().await - }; - Backend::Dynamo(aws_sdk_dynamodb::Client::new(&config)) - } -} - -fn to_dynamo_timestamp(time: SystemTime) -> AttributeValue { - AttributeValue::N( - time.duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - .to_string(), - ) -} - -#[derive(Clone, Debug, Default)] -struct Local { - events: HashMap, - questions: HashMap>, - questions_by_eid: HashMap>, -} - -mod ask; -mod event; -mod list; -mod new; -mod questions; -mod toggle; -mod vote; - -async fn get_secret(dynamo: &Backend, eid: &Ulid) -> Result { - match dynamo { - Backend::Dynamo(dynamo) => { - match dynamo - .get_item() - .table_name("events") - .key("id", AttributeValue::S(eid.to_string())) - .projection_expression("secret") - .send() - .await - { - Ok(v) => { - if let Some(s) = v - .item() - .and_then(|e| e.get("secret")) - .and_then(|s| s.as_s().ok()) - { - Ok(s.clone()) - } else { - warn!(%eid, "attempted to access non-existing event"); - Err(StatusCode::NOT_FOUND) - } - } - Err(e) => { - error!(%eid, error = %e, "dynamodb event request for secret verificaton failed"); - Err(http::StatusCode::INTERNAL_SERVER_ERROR) - } - } - } - Backend::Local(local) => { - let mut local = local.lock().unwrap(); - let Local { events, .. } = &mut *local; - match events.get(eid) { - Some(s) => Ok(s.clone()), - None => Err(StatusCode::NOT_FOUND), - } - } - } -} - -async fn check_secret(dynamo: &Backend, eid: &Ulid, secret: &str) -> Result<(), StatusCode> { - let s = get_secret(dynamo, eid).await?; - if s == secret { - Ok(()) - } else { - warn!(%eid, secret, "attempted to access event with incorrect secret"); - Err(StatusCode::UNAUTHORIZED) - } -} - -fn mint_service_error(e: E) -> SdkError { - SdkError::service_error( - e, - aws_smithy_runtime_api::http::Response::new( - aws_smithy_runtime_api::http::StatusCode::try_from(200).unwrap(), - SdkBody::empty(), - ), - ) -} - -/// Seed the database. -/// -/// This will register a test event (with id `00000000000000000000000000`) and -/// a number of questions for it in the database, whether it's an in-memory [`Local`] -/// database or a local instance of DynamoDB. Note that in the latter case -/// we are checking if the test event is already there, and - if so - we are _not_ seeding -/// the questions. This is to avoid creating duplicated questions when re-running the app. -/// And this is not an issue of course when running against our in-memory [`Local`] database. -/// -/// The returned vector contains IDs of the questions related to the test event. -#[cfg(debug_assertions)] -async fn seed(backend: &mut Backend) -> Vec { - #[derive(serde::Deserialize)] - struct LiveAskQuestion { - likes: usize, - text: String, - hidden: bool, - answered: bool, - #[serde(rename = "createTimeUnix")] - created: usize, - } - - let seed: Vec = serde_json::from_str(SEED).unwrap(); - let seed_e = Ulid::from_string("00000000000000000000000000").unwrap(); - let seed_e_secret = "secret"; - - info!("going to seed test event"); - match backend.event(&seed_e).await.unwrap() { - output if output.item().is_some() => { - warn!("test event is already there, skipping seeding questions"); - } - _ => { - backend.new(&seed_e, seed_e_secret).await.unwrap(); - info!("successfully registered test event, going to seed questions now"); - // first create questions ... - let mut qs = Vec::new(); - for q in seed { - let qid = ulid::Ulid::new(); - backend - .ask( - &seed_e, - &qid, - ask::Question { - body: q.text, - asker: None, - }, - ) - .await - .unwrap(); - qs.push((qid, q.created, q.likes, q.hidden, q.answered)); - } - // ... then set the vote count + answered/hidden flags - match backend { - Backend::Dynamo(ref mut client) => { - use aws_sdk_dynamodb::types::BatchStatementRequest; - // DynamoDB supports batch operations using PartiQL syntax with `25` as max batch size - // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchExecuteStatement.html - for chunk in qs.chunks(25) { - let batch_update = chunk - .iter() - .map(|(qid, created, votes, hidden, answered)| { - let builder = BatchStatementRequest::builder(); - let builder = if *answered { - builder.statement( - // numerous words are reserved in the DynamoDB engine (e.g. Key, Id, When) and - // should be qouted; we are quoting all of our attrs to avoid possible collisions - r#"UPDATE "questions" SET "answered"=? SET "votes"=? SET "when"=? SET "hidden"=? WHERE "id"=?"#, - ) - .parameters(to_dynamo_timestamp(SystemTime::now())) // answered - } else { - builder.statement( - r#"UPDATE "questions" SET "votes"=? SET "when"=? SET "hidden"=? WHERE "id"=?"#, - ) - }; - builder - .parameters(AttributeValue::N(votes.to_string())) // votes - .parameters(AttributeValue::N(created.to_string())) // when - .parameters(AttributeValue::Bool(*hidden)) // hidden - .parameters(AttributeValue::S(qid.to_string())) // id - .build() - .unwrap() - }) - .collect::>(); - client - .batch_execute_statement() - .set_statements(Some(batch_update)) - .send() - .await - .expect("batch to have been written ok"); - } - } - Backend::Local(ref mut state) => { - let state = Arc::get_mut(state).unwrap(); - let state = Mutex::get_mut(state).unwrap(); - for (qid, created, votes, hidden, answered) in qs { - let q = state.questions.get_mut(&qid).unwrap(); - q.insert("votes", AttributeValue::N(votes.to_string())); - if answered { - q.insert("answered", to_dynamo_timestamp(SystemTime::now())); - } - q.insert("hidden", AttributeValue::Bool(hidden)); - q.insert("when", AttributeValue::N(created.to_string())); - } - } - } - info!("successfully registered questions"); - } - } - // let's collect ids of the questions related to the test event, - // we can then use them to auto-generate user votes over time - backend - .list(&seed_e, true) - .await - .expect("scenned index ok") - .items() - .iter() - .filter_map(|item| { - let id = item - .get("id") - .expect("id is in projection") - .as_s() - .expect("id is of type string"); - ulid::Ulid::from_string(id).ok() - }) - .collect() -} +use wewerewondering_api::build_app; #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) - // TODO: we may _not_ want `without_time` when deploying - // TODO: on non-Lambda runtimes; this can be addressed as - // TODO: part of https://github.com/jonhoo/wewerewondering/issues/202 - .without_time(/* cloudwatch does that */).init(); - - #[cfg(not(debug_assertions))] - let backend = Backend::dynamo().await; - - #[cfg(debug_assertions)] - let backend = { - use rand::prelude::IndexedRandom; - - let mut backend = if std::env::var_os("USE_DYNAMODB").is_some() { - Backend::dynamo().await - } else { - Backend::local().await - }; - - // to aid in development, seed the backend with a test event and related - // questions, and auto-generate user votes over time - let qids = seed(&mut backend).await; - let cheat = backend.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(1)); - interval.tick().await; - loop { - interval.tick().await; - let qid = qids - .choose(&mut rand::rng()) - .expect("there _are_ some questions for our test event"); - let _ = cheat.vote(qid, vote::UpDown::Up).await; - } - }); - - backend - }; - - let app = Router::new() - .route("/api/event", post(new::new)) - .route("/api/event/{eid}", post(ask::ask)) - .route("/api/event/{eid}", get(event::event)) - .route("/api/event/{eid}/questions", get(list::list)) - .route("/api/event/{eid}/questions/{secret}", get(list::list_all)) - .route( - "/api/event/{eid}/questions/{secret}/{qid}/toggle/{property}", - post(toggle::toggle), - ) - .route("/api/vote/{qid}/{updown}", post(vote::vote)) - .route("/api/questions/{qids}", get(questions::questions)) - .layer(RequestBodyLimitLayer::new(1024)) - .with_state(backend); - - if cfg!(debug_assertions) { - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); - let listener = tokio::net::TcpListener::bind(&addr).await?; - Ok(axum::serve(listener, app.into_make_service()).await?) - } else { - // If we compile in release mode, use the Lambda Runtime - // To run with AWS Lambda runtime, wrap in our `LambdaLayer` - let app = tower::ServiceBuilder::new() - .layer(TraceLayer::new_for_http()) - .layer(LambdaLayer) - .service(app); - - Ok(lambda_http::run(app).await?) - } -} - -#[derive(Clone, Copy)] -pub struct LambdaLayer; - -impl Layer for LambdaLayer { - type Service = LambdaService; - - fn layer(&self, inner: S) -> Self::Service { - LambdaService { inner } - } -} - -pub struct LambdaService { - inner: S, -} - -impl Service for LambdaService -where - S: Service>, - S::Response: axum::response::IntoResponse + Send + 'static, - S::Error: std::error::Error + Send + Sync + 'static, - S::Future: Send + 'static, -{ - type Response = lambda_http::Response; - type Error = lambda_http::Error; - type Future = - Pin> + Send + 'static>>; - - fn poll_ready( - &mut self, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - self.inner.poll_ready(cx).map_err(Into::into) - } - - fn call(&mut self, req: lambda_http::Request) -> Self::Future { - let (parts, body) = req.into_parts(); - let body = match body { - lambda_http::Body::Empty => axum::body::Body::default(), - lambda_http::Body::Text(t) => t.into(), - lambda_http::Body::Binary(v) => v.into(), - }; - - let request = axum::http::Request::from_parts(parts, body); - - let fut = self.inner.call(request); - let fut = async move { - let resp = fut.await?; - let (parts, body) = resp.into_response().into_parts(); - let bytes = body.collect().await?.to_bytes(); - let bytes: &[u8] = &bytes; - let resp: hyper::Response = match std::str::from_utf8(bytes) { - Ok(s) => hyper::Response::from_parts(parts, s.into()), - Err(_) => hyper::Response::from_parts(parts, bytes.into()), - }; - Ok(resp) - }; - - Box::pin(fut) - } + .init(); + let app = build_app().await; + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); + let listener = tokio::net::TcpListener::bind(&addr).await?; + Ok(axum::serve(listener, app.into_make_service()).await?) } diff --git a/server/src/new.rs b/server/src/new.rs index a04b869..f2b310a 100644 --- a/server/src/new.rs +++ b/server/src/new.rs @@ -1,6 +1,5 @@ -use crate::{to_dynamo_timestamp, EVENTS_TTL}; - use super::{Backend, Local}; +use crate::{utils, EVENTS_TTL}; use aws_sdk_dynamodb::{ error::SdkError, operation::put_item::{PutItemError, PutItemOutput}, @@ -32,10 +31,10 @@ impl Backend { .table_name("events") .item("id", AttributeValue::S(eid.to_string())) .item("secret", AttributeValue::S(secret.into())) - .item("when", to_dynamo_timestamp(SystemTime::now())) + .item("when", utils::to_dynamo_timestamp(SystemTime::now())) .item( "expire", - to_dynamo_timestamp(SystemTime::now() + EVENTS_TTL), + utils::to_dynamo_timestamp(SystemTime::now() + EVENTS_TTL), ) .send() .await diff --git a/server/src/toggle.rs b/server/src/toggle.rs index e7cdb63..d104b3f 100644 --- a/server/src/toggle.rs +++ b/server/src/toggle.rs @@ -1,5 +1,5 @@ -use super::{Backend, Local}; -use crate::to_dynamo_timestamp; +use crate::utils; +use crate::{Backend, Local}; use aws_sdk_dynamodb::{ error::SdkError, operation::update_item::{UpdateItemError, UpdateItemOutput}, @@ -52,7 +52,10 @@ impl Backend { if let Some(time) = time { q.update_expression("SET #field = :set") .expression_attribute_names("#field", "answered") - .expression_attribute_values(":set", to_dynamo_timestamp(time)) + .expression_attribute_values( + ":set", + utils::to_dynamo_timestamp(time), + ) } else { q.update_expression("REMOVE #field") .expression_attribute_names("#field", "answered") @@ -72,7 +75,7 @@ impl Backend { ToggleRequest::Hidden(set) => q.insert("hidden", AttributeValue::Bool(set)), ToggleRequest::Answered(time) => { if let Some(time) = time { - q.insert("answered", to_dynamo_timestamp(time)) + q.insert("answered", utils::to_dynamo_timestamp(time)) } else { q.remove("answered") } @@ -90,7 +93,7 @@ pub(super) async fn toggle( State(dynamo): State, body: String, ) -> Result, StatusCode> { - super::check_secret(&dynamo, &eid, &secret).await?; + utils::check_secret(&dynamo, &eid, &secret).await?; let req = match (&*body, property) { ("on", Property::Hidden) => ToggleRequest::Hidden(true), diff --git a/server/src/utils.rs b/server/src/utils.rs new file mode 100644 index 0000000..6cbf408 --- /dev/null +++ b/server/src/utils.rs @@ -0,0 +1,212 @@ +use crate::{Backend, Local}; +use aws_sdk_dynamodb::{error::SdkError, types::AttributeValue}; +use aws_smithy_types::body::SdkBody; +use http::StatusCode; +use std::time::SystemTime; +use tracing::{error, warn}; +use ulid::Ulid; + +pub fn to_dynamo_timestamp(time: SystemTime) -> AttributeValue { + AttributeValue::N( + time.duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(), + ) +} + +pub fn mint_service_error(e: E) -> SdkError { + SdkError::service_error( + e, + aws_smithy_runtime_api::http::Response::new( + aws_smithy_runtime_api::http::StatusCode::try_from(200).unwrap(), + SdkBody::empty(), + ), + ) +} + +pub async fn get_secret(dynamo: &Backend, eid: &Ulid) -> Result { + match dynamo { + Backend::Dynamo(dynamo) => { + match dynamo + .get_item() + .table_name("events") + .key("id", AttributeValue::S(eid.to_string())) + .projection_expression("secret") + .send() + .await + { + Ok(v) => { + if let Some(s) = v + .item() + .and_then(|e| e.get("secret")) + .and_then(|s| s.as_s().ok()) + { + Ok(s.clone()) + } else { + warn!(%eid, "attempted to access non-existing event"); + Err(StatusCode::NOT_FOUND) + } + } + Err(e) => { + error!(%eid, error = %e, "dynamodb event request for secret verificaton failed"); + Err(http::StatusCode::INTERNAL_SERVER_ERROR) + } + } + } + Backend::Local(local) => { + let mut local = local.lock().unwrap(); + let Local { events, .. } = &mut *local; + match events.get(eid) { + Some(s) => Ok(s.clone()), + None => Err(StatusCode::NOT_FOUND), + } + } + } +} + +pub async fn check_secret(dynamo: &Backend, eid: &Ulid, secret: &str) -> Result<(), StatusCode> { + let s = get_secret(dynamo, eid).await?; + if s == secret { + Ok(()) + } else { + warn!(%eid, secret, "attempted to access event with incorrect secret"); + Err(StatusCode::UNAUTHORIZED) + } +} + +/// Seed the database. +/// +/// This will register a test event (with id `00000000000000000000000000`) and +/// a number of questions for it in the database, whether it's an in-memory [`Local`] +/// database or a local instance of DynamoDB. Note that in the latter case +/// we are checking if the test event is already there, and - if so - we are _not_ seeding +/// the questions. This is to avoid creating duplicated questions when re-running the app. +/// And this is not an issue of course when running against our in-memory [`Local`] database. +/// +/// The returned vector contains IDs of the questions related to the test event. +#[cfg(debug_assertions)] +pub(crate) async fn seed(backend: &mut Backend) -> Vec { + use crate::{ask, SEED}; + use std::sync::{Arc, Mutex}; + use tracing::{info, warn}; + + #[derive(serde::Deserialize)] + struct LiveAskQuestion { + likes: usize, + text: String, + hidden: bool, + answered: bool, + #[serde(rename = "createTimeUnix")] + created: usize, + } + + let seed: Vec = serde_json::from_str(SEED).unwrap(); + let seed_e = Ulid::from_string("00000000000000000000000000").unwrap(); + let seed_e_secret = "secret"; + + info!("going to seed test event"); + match backend.event(&seed_e).await.unwrap() { + output if output.item().is_some() => { + warn!("test event is already there, skipping seeding questions"); + } + _ => { + backend.new(&seed_e, seed_e_secret).await.unwrap(); + info!("successfully registered test event, going to seed questions now"); + // first create questions ... + let mut qs = Vec::new(); + for q in seed { + let qid = ulid::Ulid::new(); + backend + .ask( + &seed_e, + &qid, + ask::Question { + body: q.text, + asker: None, + }, + ) + .await + .unwrap(); + qs.push((qid, q.created, q.likes, q.hidden, q.answered)); + } + // ... then set the vote count + answered/hidden flags + match backend { + Backend::Dynamo(ref mut client) => { + use aws_sdk_dynamodb::types::BatchStatementRequest; + // DynamoDB supports batch operations using PartiQL syntax with `25` as max batch size + // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchExecuteStatement.html + for chunk in qs.chunks(25) { + let batch_update = chunk + .iter() + .map(|(qid, created, votes, hidden, answered)| { + let builder = BatchStatementRequest::builder(); + let builder = if *answered { + builder.statement( + // numerous words are reserved in the DynamoDB engine (e.g. Key, Id, When) and + // should be qouted; we are quoting all of our attrs to avoid possible collisions + r#"UPDATE "questions" SET "answered"=? SET "votes"=? SET "when"=? SET "hidden"=? WHERE "id"=?"#, + ) + .parameters(to_dynamo_timestamp(SystemTime::now())) // answered + } else { + builder.statement( + r#"UPDATE "questions" SET "votes"=? SET "when"=? SET "hidden"=? WHERE "id"=?"#, + ) + }; + builder + .parameters(AttributeValue::N(votes.to_string())) // votes + .parameters(AttributeValue::N(created.to_string())) // when + .parameters(AttributeValue::Bool(*hidden)) // hidden + .parameters(AttributeValue::S(qid.to_string())) // id + .build() + .unwrap() + }) + .collect::>(); + client + .batch_execute_statement() + .set_statements(Some(batch_update)) + .send() + .await + .expect("batch to have been written ok"); + } + } + Backend::Local(ref mut state) => { + let state = Arc::get_mut(state).unwrap(); + let state = Mutex::get_mut(state).unwrap(); + for (qid, created, votes, hidden, answered) in qs { + let q = state.questions.get_mut(&qid).unwrap(); + q.insert("votes", AttributeValue::N(votes.to_string())); + if answered { + q.insert("answered", to_dynamo_timestamp(SystemTime::now())); + } + q.insert("hidden", AttributeValue::Bool(hidden)); + q.insert("when", AttributeValue::N(created.to_string())); + } + } + } + info!("successfully registered questions"); + } + } + // let's collect ids of the questions related to the test event, + // we can then use them to auto-generate user votes over time + backend + .list(&seed_e, true) + .await + .expect("scenned index ok") + .items() + .iter() + .filter_map(|item| { + let id = item + .get("id") + .expect("id is in projection") + .as_s() + .expect("id is of type string"); + // NB! If you are creating entries manually via the DynamoDB Web UI (or CLI) + // when developing and testing, make sure you are putting valid ulids as ids, + // since the db server will only check that `id` respects the `S` type, and so + // will not error back to you when you are saving a question (say, via Web UI) + // with `id="string-that-is-not-valid-ulid"`. + Some(ulid::Ulid::from_string(id).expect("all ids to be valid ulids in the table")) + }) + .collect() +} diff --git a/server/src/vote.rs b/server/src/vote.rs index 4456f2f..f78ed15 100644 --- a/server/src/vote.rs +++ b/server/src/vote.rs @@ -15,13 +15,13 @@ use tracing::{debug, error, info, trace, warn}; #[derive(Deserialize, Debug, Copy, Clone)] #[serde(rename_all = "lowercase")] -pub(super) enum UpDown { +pub enum UpDown { Up, Down, } impl Backend { - pub(super) async fn vote( + pub async fn vote( &self, qid: &Ulid, direction: UpDown, From f7e6d9b77a009812820f3dbb1b76b75601ba774d Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 19 Jan 2025 13:28:55 +0400 Subject: [PATCH 02/15] Ty clippy --- server/src/utils.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/utils.rs b/server/src/utils.rs index 6cbf408..323bdcf 100644 --- a/server/src/utils.rs +++ b/server/src/utils.rs @@ -195,7 +195,7 @@ pub(crate) async fn seed(backend: &mut Backend) -> Vec { .expect("scenned index ok") .items() .iter() - .filter_map(|item| { + .map(|item| { let id = item .get("id") .expect("id is in projection") @@ -206,7 +206,7 @@ pub(crate) async fn seed(backend: &mut Backend) -> Vec { // since the db server will only check that `id` respects the `S` type, and so // will not error back to you when you are saving a question (say, via Web UI) // with `id="string-that-is-not-valid-ulid"`. - Some(ulid::Ulid::from_string(id).expect("all ids to be valid ulids in the table")) + ulid::Ulid::from_string(id).expect("all ids to be valid ulids in the table") }) .collect() } From b2d43e855014e362b31c926a25908b3be960f8fc Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 19 Jan 2025 14:49:14 +0400 Subject: [PATCH 03/15] Update deploy docs --- README.md | 4 ++-- infra/lambda.tf | 11 ++++++++--- server/src/lib.rs | 14 +++++++------- server/template.yaml | 3 +++ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 147257c..0eb0206 100644 --- a/README.md +++ b/README.md @@ -299,8 +299,8 @@ To deploy server: ```console cd server -cargo lambda build --release --arm64 -cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,wewerewondering_api=trace --profile qa +cargo lambda build --release --arm64 --bin lambda +cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,lambda=trace --profile qa --binary-name lambda ``` To deploy client: diff --git a/infra/lambda.tf b/infra/lambda.tf index 0d2137e..5b791e6 100644 --- a/infra/lambda.tf +++ b/infra/lambda.tf @@ -75,16 +75,21 @@ resource "aws_iam_role" "www" { } } +// To build for AWS Lambda runtime, run: +// ```console +// $ cargo lambda build --release --arm64 --bin lambda +// ``` +// The artifact will be located in /server/target/lambda/lambda/bootstrap, check "lambda-built" { assert { - condition = fileexists("${path.module}/../server/target/lambda/wewerewondering-api/bootstrap") - error_message = "Run `cargo lambda build --release --arm64` in ../server" + condition = fileexists("${path.module}/../server/target/lambda/lambda/bootstrap") + error_message = "Run `cargo lambda build --release --arm64 --bin lambda` in ../server" } } data "archive_file" "lambda" { type = "zip" - source_file = "${path.module}/../server/target/lambda/wewerewondering-api/bootstrap" + source_file = "${path.module}/../server/target/lambda/lambda/bootstrap" output_path = "lambda_function_payload.zip" } diff --git a/server/src/lib.rs b/server/src/lib.rs index c56d0e7..66bdf49 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -138,16 +138,16 @@ pub async fn build_app() -> Router { Router::new() .route("/api/event", post(new::new)) - .route("/api/event/:eid", post(ask::ask)) - .route("/api/event/:eid", get(event::event)) - .route("/api/event/:eid/questions", get(list::list)) - .route("/api/event/:eid/questions/:secret", get(list::list_all)) + .route("/api/event/{eid}", post(ask::ask)) + .route("/api/event/{eid}", get(event::event)) + .route("/api/event/{eid}/questions", get(list::list)) + .route("/api/event/{eid}/questions/{secret}", get(list::list_all)) .route( - "/api/event/:eid/questions/:secret/:qid/toggle/:property", + "/api/event/{eid}/questions/{secret}/{qid}/toggle/{property}", post(toggle::toggle), ) - .route("/api/vote/:qid/:updown", post(vote::vote)) - .route("/api/questions/:qids", get(questions::questions)) + .route("/api/vote/{qid}/{updown}", post(vote::vote)) + .route("/api/questions/{qids}", get(questions::questions)) .layer(RequestBodyLimitLayer::new(1024)) .with_state(backend) } diff --git a/server/template.yaml b/server/template.yaml index 4c70bdd..0b621d3 100644 --- a/server/template.yaml +++ b/server/template.yaml @@ -9,8 +9,11 @@ Description: Sample SAM Template for running and tesing WeWereWondering locally Resources: WeWereWonderingApi: Type: AWS::Serverless::Function + # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/building-rust.html Metadata: BuildMethod: rust-cargolambda + BuildProperties: + Binary: lambda Properties: CodeUri: . Handler: bootstrap From 2e82b3d807ce179865a6bdaae23b22cef385488d Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 19 Jan 2025 15:12:14 +0400 Subject: [PATCH 04/15] Add --bin lambda to CI jobs --- .github/workflows/terraform-apply.yml | 2 +- .github/workflows/terraform-plan.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 30de23d..67a3f62 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -68,7 +68,7 @@ jobs: - name: Install zig for cargo-lambda run: sudo snap install zig --classic --beta - - run: cargo lambda build --release --arm64 + - run: cargo lambda build --release --arm64 --bin lambda working-directory: ./server - uses: hashicorp/tfc-workflows-github/actions/upload-configuration@v1.3.2 diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 9abf4d9..6dc82cb 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -66,7 +66,7 @@ jobs: - name: Install zig for cargo-lambda run: sudo snap install zig --classic --beta - - run: cargo lambda build --release --arm64 + - run: cargo lambda build --release --arm64 --bin lambda working-directory: ./server - uses: hashicorp/tfc-workflows-github/actions/upload-configuration@v1.3.2 From 95c5c498ba4da0b5b7fd685fac3e4775823384ff Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 19 Jan 2025 16:03:23 +0400 Subject: [PATCH 05/15] Allow Backend::local unused --- server/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/lib.rs b/server/src/lib.rs index 66bdf49..0fa9017 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -43,6 +43,7 @@ pub enum Backend { } impl Backend { + #[allow(unused)] async fn local() -> Self { Backend::Local(Arc::new(Mutex::new(Local::default()))) } From cc89d6f0dcc8ef661f2a64fa818d1d07e38cf67a Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Mon, 20 Jan 2025 23:26:58 +0400 Subject: [PATCH 06/15] Add default run to Cargo.toml --- server/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/server/Cargo.toml b/server/Cargo.toml index f64c8d4..95a904c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -2,6 +2,7 @@ name = "wewerewondering-api" version = "0.1.0" edition = "2021" +default-run = "wewerewondering-api" [dependencies] aws-config = { version = "1.5.0", features = ["behavior-version-latest"] } From 3f5a33ff54595fe16ce61e37da722d245edc4a73 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 9 Feb 2025 13:45:49 +0400 Subject: [PATCH 07/15] Use IndexedRandom trait --- server/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/lib.rs b/server/src/lib.rs index 0fa9017..f864b0b 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -110,7 +110,7 @@ pub async fn build_app() -> Router { #[cfg(debug_assertions)] let backend = { - use rand::prelude::SliceRandom; + use rand::prelude::IndexedRandom; let mut backend = if std::env::var_os("USE_DYNAMODB").is_some() { Backend::dynamo().await @@ -128,7 +128,7 @@ pub async fn build_app() -> Router { loop { interval.tick().await; let qid = qids - .choose(&mut rand::thread_rng()) + .choose(&mut rand::rng()) .expect("there _are_ some questions for our test event"); let _ = cheat.vote(qid, vote::UpDown::Up).await; } From f296fc4f1e28c7f7017a38e53fc9fabf027b16b7 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 9 Feb 2025 14:03:03 +0400 Subject: [PATCH 08/15] Rename build_app -> new --- server/src/lambda.rs | 4 ++-- server/src/lib.rs | 2 +- server/src/main.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/lambda.rs b/server/src/lambda.rs index 27aab5e..f793f6d 100644 --- a/server/src/lambda.rs +++ b/server/src/lambda.rs @@ -6,7 +6,7 @@ use tower::Layer; use tower_http::trace::TraceLayer; use tower_service::Service; use tracing_subscriber::EnvFilter; -use wewerewondering_api::build_app; +use wewerewondering_api::new; #[tokio::main] async fn main() -> Result<(), Error> { @@ -15,7 +15,7 @@ async fn main() -> Result<(), Error> { .without_time(/* cloudwatch does that */) .init(); - let app = build_app().await; + let app = new().await; // To run with AWS Lambda runtime, wrap in our `LambdaLayer` let app = tower::ServiceBuilder::new() .layer(TraceLayer::new_for_http()) diff --git a/server/src/lib.rs b/server/src/lib.rs index f864b0b..022da23 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -104,7 +104,7 @@ impl Backend { } } -pub async fn build_app() -> Router { +pub async fn new() -> Router { #[cfg(not(debug_assertions))] let backend = Backend::dynamo().await; diff --git a/server/src/main.rs b/server/src/main.rs index cbb1d41..2dd9fc4 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,13 +1,13 @@ use lambda_http::Error; use tracing_subscriber::EnvFilter; -use wewerewondering_api::build_app; +use wewerewondering_api::new; #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); - let app = build_app().await; + let app = new().await; let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); let listener = tokio::net::TcpListener::bind(&addr).await?; Ok(axum::serve(listener, app.into_make_service()).await?) From 6103bcedf79fa97b490e573d35caf06bb19d0f97 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 9 Feb 2025 15:40:43 +0400 Subject: [PATCH 09/15] Add CargoLambda.toml config file --- server/CargoLambda.toml | 17 +++++++++++++++++ server/template.yaml | 2 -- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 server/CargoLambda.toml diff --git a/server/CargoLambda.toml b/server/CargoLambda.toml new file mode 100644 index 0000000..e65b2e9 --- /dev/null +++ b/server/CargoLambda.toml @@ -0,0 +1,17 @@ +# https://www.cargo-lambda.info/guide/configuration.html#global-configuration-files +# +# This allows us to offload the `cargo build` command, so we can run +# `cargo lambda build --release --arm64` on CI only specifying profile and +# architecture. If we decide to change the binary's name, we will be no need to +# go and adjust configuration neither on the CI workflows, nor in the SAM's +# `template.yaml` that we are using to run a local instance of API Gateway +# for testing purposes. +# +# NB! IF we decide to add more build parameters here, let's not forget to check +# that sam local is still building and running ok with: +# ```console +# $ sam build +# S sam local start-api +# ``` +[build] +bin = ["lambda"] diff --git a/server/template.yaml b/server/template.yaml index 0b621d3..f265e70 100644 --- a/server/template.yaml +++ b/server/template.yaml @@ -12,8 +12,6 @@ Resources: # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/building-rust.html Metadata: BuildMethod: rust-cargolambda - BuildProperties: - Binary: lambda Properties: CodeUri: . Handler: bootstrap From 3d49629d5c28a98f61a5a0e28eb458505b5528a1 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 9 Feb 2025 15:50:37 +0400 Subject: [PATCH 10/15] Add binary name to deploy in CargoLambda.toml --- .github/workflows/terraform-apply.yml | 2 +- .github/workflows/terraform-plan.yml | 2 +- README.md | 4 ++-- server/CargoLambda.toml | 3 +++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index 67a3f62..30de23d 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -68,7 +68,7 @@ jobs: - name: Install zig for cargo-lambda run: sudo snap install zig --classic --beta - - run: cargo lambda build --release --arm64 --bin lambda + - run: cargo lambda build --release --arm64 working-directory: ./server - uses: hashicorp/tfc-workflows-github/actions/upload-configuration@v1.3.2 diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 6dc82cb..9abf4d9 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -66,7 +66,7 @@ jobs: - name: Install zig for cargo-lambda run: sudo snap install zig --classic --beta - - run: cargo lambda build --release --arm64 --bin lambda + - run: cargo lambda build --release --arm64 working-directory: ./server - uses: hashicorp/tfc-workflows-github/actions/upload-configuration@v1.3.2 diff --git a/README.md b/README.md index 0eb0206..7710014 100644 --- a/README.md +++ b/README.md @@ -299,8 +299,8 @@ To deploy server: ```console cd server -cargo lambda build --release --arm64 --bin lambda -cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,lambda=trace --profile qa --binary-name lambda +cargo lambda build --release --arm64 +cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,lambda=trace --profile qa ``` To deploy client: diff --git a/server/CargoLambda.toml b/server/CargoLambda.toml index e65b2e9..7f593c8 100644 --- a/server/CargoLambda.toml +++ b/server/CargoLambda.toml @@ -15,3 +15,6 @@ # ``` [build] bin = ["lambda"] + +[deploy] +binary_name = "lambda" From 0f94f9b5462958b8d8733a75ca76eb122cfabaeb Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 9 Feb 2025 16:16:40 +0400 Subject: [PATCH 11/15] Restore wewerewondering_api=trace in README notes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7710014..0a9cc9f 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ To deploy server: ```console cd server cargo lambda build --release --arm64 -cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,lambda=trace --profile qa +cargo lambda deploy --env-var RUST_LOG=info,tower_http=debug,wewerewondering_api=trace,lambda=trace --profile qa ``` To deploy client: From 9004d86ba7a5815ebcfc88c253eb0cb092dc5f98 Mon Sep 17 00:00:00 2001 From: Pavel Mikhalkevich Date: Sun, 9 Feb 2025 18:34:17 +0400 Subject: [PATCH 12/15] Upd docs in infra/lambda.tf --- infra/lambda.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/lambda.tf b/infra/lambda.tf index 5b791e6..f8c22df 100644 --- a/infra/lambda.tf +++ b/infra/lambda.tf @@ -77,13 +77,13 @@ resource "aws_iam_role" "www" { // To build for AWS Lambda runtime, run: // ```console -// $ cargo lambda build --release --arm64 --bin lambda +// $ cargo lambda build --release --arm64 // ``` // The artifact will be located in /server/target/lambda/lambda/bootstrap, check "lambda-built" { assert { condition = fileexists("${path.module}/../server/target/lambda/lambda/bootstrap") - error_message = "Run `cargo lambda build --release --arm64 --bin lambda` in ../server" + error_message = "Run `cargo lambda build --release --arm64` in ../server" } } From 2f17ca4a0058c5df6dc26f17639d0cd147ff217f Mon Sep 17 00:00:00 2001 From: Jon Gjengset Date: Sun, 9 Feb 2025 15:38:40 +0100 Subject: [PATCH 13/15] Avoid free-standing new at callsites --- server/src/lambda.rs | 3 +-- server/src/main.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/server/src/lambda.rs b/server/src/lambda.rs index f793f6d..3386eb6 100644 --- a/server/src/lambda.rs +++ b/server/src/lambda.rs @@ -6,7 +6,6 @@ use tower::Layer; use tower_http::trace::TraceLayer; use tower_service::Service; use tracing_subscriber::EnvFilter; -use wewerewondering_api::new; #[tokio::main] async fn main() -> Result<(), Error> { @@ -15,7 +14,7 @@ async fn main() -> Result<(), Error> { .without_time(/* cloudwatch does that */) .init(); - let app = new().await; + let app = wewerewondering_api::new().await; // To run with AWS Lambda runtime, wrap in our `LambdaLayer` let app = tower::ServiceBuilder::new() .layer(TraceLayer::new_for_http()) diff --git a/server/src/main.rs b/server/src/main.rs index 2dd9fc4..b824819 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,13 +1,12 @@ use lambda_http::Error; use tracing_subscriber::EnvFilter; -use wewerewondering_api::new; #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); - let app = new().await; + let app = wewerewondering_api::new().await; let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); let listener = tokio::net::TcpListener::bind(&addr).await?; Ok(axum::serve(listener, app.into_make_service()).await?) From bffa993bc0e8ca1d0926e5fba1d2059420f5ec70 Mon Sep 17 00:00:00 2001 From: Jon Gjengset Date: Sun, 9 Feb 2025 15:54:11 +0100 Subject: [PATCH 14/15] Maybe make plan work --- .github/workflows/terraform-plan.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 9abf4d9..947280c 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -73,6 +73,7 @@ jobs: id: upload with: workspace: ${{ env.TF_WORKSPACE }} + token: ${{ vars.TF_API_PLAN_TOKEN }} # directory: ${{ env.CONFIG_DIRECTORY }} speculative: true @@ -83,6 +84,7 @@ jobs: continue-on-error: true with: workspace: ${{ env.TF_WORKSPACE }} + token: ${{ vars.TF_API_PLAN_TOKEN }} configuration_version: ${{ steps.upload.outputs.configuration_version_id }} plan_only: true message: "Triggered From GitHub Actions CI ${{ github.sha }}" @@ -90,6 +92,7 @@ jobs: - uses: hashicorp/tfc-workflows-github/actions/plan-output@v1.3.2 id: plan-output with: + token: ${{ vars.TF_API_PLAN_TOKEN }} plan: ${{ steps.run.outputs.plan_id }} - uses: actions/github-script@v7 From dcf76c2bfc59078e2681901d5ca7cea95d3735db Mon Sep 17 00:00:00 2001 From: Jon Gjengset Date: Sun, 9 Feb 2025 15:58:31 +0100 Subject: [PATCH 15/15] apparently not --- .github/workflows/terraform-plan.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 947280c..9abf4d9 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -73,7 +73,6 @@ jobs: id: upload with: workspace: ${{ env.TF_WORKSPACE }} - token: ${{ vars.TF_API_PLAN_TOKEN }} # directory: ${{ env.CONFIG_DIRECTORY }} speculative: true @@ -84,7 +83,6 @@ jobs: continue-on-error: true with: workspace: ${{ env.TF_WORKSPACE }} - token: ${{ vars.TF_API_PLAN_TOKEN }} configuration_version: ${{ steps.upload.outputs.configuration_version_id }} plan_only: true message: "Triggered From GitHub Actions CI ${{ github.sha }}" @@ -92,7 +90,6 @@ jobs: - uses: hashicorp/tfc-workflows-github/actions/plan-output@v1.3.2 id: plan-output with: - token: ${{ vars.TF_API_PLAN_TOKEN }} plan: ${{ steps.run.outputs.plan_id }} - uses: actions/github-script@v7