diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d2a7ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.env +target/ +tests/ +Dockerfile +scripts/ +migrations/ diff --git a/.sqlx/query-4b0ad859d2b54b0e4b34d2b56b993d973001526cc4ae39f99abfcc3f94d4a91d.json b/.sqlx/query-4b0ad859d2b54b0e4b34d2b56b993d973001526cc4ae39f99abfcc3f94d4a91d.json new file mode 100644 index 0000000..fa89f38 --- /dev/null +++ b/.sqlx/query-4b0ad859d2b54b0e4b34d2b56b993d973001526cc4ae39f99abfcc3f94d4a91d.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO subscriptions (id, email, name, subscribed_at)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "4b0ad859d2b54b0e4b34d2b56b993d973001526cc4ae39f99abfcc3f94d4a91d" +} diff --git a/Cargo.lock b/Cargo.lock index 2e901e5..b3ca1a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1985,6 +1985,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-aux" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d2e8bfba469d06512e11e3311d4d051a4a387a5b42d010404fecf3200321c95" +dependencies = [ + "chrono", + "serde", + "serde_json", +] + [[package]] name = "serde_derive" version = "1.0.197" @@ -3155,6 +3166,7 @@ dependencies = [ "reqwest", "secrecy", "serde", + "serde-aux", "sqlx", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index d1ed519..0db5c9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ config = "0.14.0" log = "0.4.21" secrecy = { version = "0.8.0", features = ["serde"] } serde = { version = "1.0.197", features = ["derive"] } +serde-aux = "4.5.0" tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread"] } tracing = { version = "0.1.40", features = ["log"] } tracing-actix-web = "0.7.10" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fedd17f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM rust:1.77.0 as builder + +WORKDIR /app +RUN apt update && apt install -y lld clang +COPY . . +ENV SQLX_OFFLINE true +RUN cargo build --release + +FROM debian:bookworm-slim as runtime +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /app/target/release/zero2prod zero2prod +COPY configuration configuration +ENV APP_ENVIRONMENT production +ENTRYPOINT [ "./zero2prod" ] \ No newline at end of file diff --git a/README.md b/README.md index 20c507d..9682f83 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,9 @@ To disable a clippy warning: `#[allow(clippy::lint_name)]` - [ ] Understand all those tracing related crates (e.g., `tracing::instrument`). - [ ] Wire tracing with open telemetry (`tracing-opentelemetry`). - [ ] `tracing` v.s. `log`. + + +### Ch5 + +- [ ] Use a container image for the app. E.g. [Wolfi](https://github.com/wolfi-dev/). +- [ ] Deploy to cloudflare if possible. diff --git a/chef.Dockerfile b/chef.Dockerfile new file mode 100644 index 0000000..c469423 --- /dev/null +++ b/chef.Dockerfile @@ -0,0 +1,30 @@ +FROM lukemathwalker/cargo-chef:latest-rust-1.72.0 as chef +WORKDIR /app +RUN apt update && apt install -y lld clang + +FROM chef as planner +COPY . . +# Compute a lock-like file for our project +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef as builder +COPY --from=planner /app/recipe.json recipe.json +# Build our project dependencies +RUN cargo chef cook --release --recipe-path recipe.json +# Up to this point, if our dependency tree stays the same, +# all layers should be cached. +COPY . . +ENV SQLX_OFFLINE true +RUN cargo build --release --bin zero2prod + +FROM debian:bookworm-slim as runtime +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /app/target/release/zero2prod zero2prod +COPY configuration configuration +ENV APP_ENVIRONMENT production +ENTRYPOINT [ "./zero2prod" ] \ No newline at end of file diff --git a/configuration.yaml b/configuration/base.yaml similarity index 82% rename from configuration.yaml rename to configuration/base.yaml index b3a7d9a..a7e4a5d 100644 --- a/configuration.yaml +++ b/configuration/base.yaml @@ -1,4 +1,5 @@ -application_port: 8000 +application: + port: 8000 database: host: "localhost" port: 5432 diff --git a/configuration/local.yaml b/configuration/local.yaml new file mode 100644 index 0000000..3b77405 --- /dev/null +++ b/configuration/local.yaml @@ -0,0 +1,4 @@ +application: + host: 127.0.0.1 +database: + require_ssl: false \ No newline at end of file diff --git a/configuration/production.yaml b/configuration/production.yaml new file mode 100644 index 0000000..f3ac210 --- /dev/null +++ b/configuration/production.yaml @@ -0,0 +1,4 @@ +application: + host: 0.0.0.0 +database: + require_ssl: true \ No newline at end of file diff --git a/spec.yaml b/spec.yaml new file mode 100644 index 0000000..b48f26c --- /dev/null +++ b/spec.yaml @@ -0,0 +1,54 @@ +name: zero2prod +# See https://www.digitalocean.com/docs/app-platform/#regional-availability for the available options +# You can get region slugs from https://www.digitalocean.com/docs/platform/availability-matrix/ +# `fra` stands for Frankfurt (Germany - EU) +region: fra +services: + - name: zero2prod + # Relative to the repository root + dockerfile_path: Dockerfile + source_dir: . + github: + branch: root-chapter-05 + deploy_on_push: true + repo: djdongjin/zero2prod + # Active probe used by DigitalOcean's to ensure our application is healthy + health_check: + # The path to our health check endpoint! It turned out to be useful in the end! + http_path: /health_check + # The port the application will be listening on for incoming requests + # It should match what we specify in our configuration.yaml file! + http_port: 8000 + # For production workloads we'd go for at least two! + instance_count: 1 + # Let's keep the bill lean for now... + instance_size_slug: basic-xxs + # All incoming requests should be routed to our app + routes: + - path: / + envs: + - key: APP_DATABASE__USERNAME + scope: RUN_TIME + value: ${newsletter.USERNAME} + - key: APP_DATABASE__PASSWORD + scope: RUN_TIME + value: ${newsletter.PASSWORD} + - key: APP_DATABASE__HOST + scope: RUN_TIME + value: ${newsletter.HOSTNAME} + - key: APP_DATABASE__PORT + scope: RUN_TIME + value: ${newsletter.PORT} + - key: APP_DATABASE__DATABASE_NAME + scope: RUN_TIME + value: ${newsletter.DATABASE} +databases: + # PG = Postgres + - engine: PG + # Database name + name: newsletter + # Again, let's keep the bill lean + num_nodes: 1 + size: db-s-dev-database + # Postgres version - using the latest here + version: "14" \ No newline at end of file diff --git a/src/configuration.rs b/src/configuration.rs index 70992f2..857c6e1 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -1,9 +1,12 @@ use secrecy::{ExposeSecret, Secret}; +use serde_aux::field_attributes::deserialize_number_from_string; +use sqlx::postgres::{PgConnectOptions, PgSslMode}; +use sqlx::ConnectOptions; #[derive(serde::Deserialize)] pub struct Settings { pub database: DatabseSettings, - pub application_port: u16, + pub application: ApplicationSettings, } #[derive(serde::Deserialize)] @@ -11,40 +14,92 @@ pub struct DatabseSettings { pub username: String, pub password: Secret, pub host: String, + #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub database_name: String, + // Determine if we demand the connection to be encrypted or not + pub require_ssl: bool, +} + +#[derive(serde::Deserialize)] +pub struct ApplicationSettings { + pub host: String, + pub port: u16, } impl DatabseSettings { - pub fn connection_string(&self) -> Secret { - Secret::new(format!( - "postgres://{}:{}@{}:{}/{}", - self.username, - self.password.expose_secret(), - self.host, - self.port, - self.database_name - )) + pub fn without_db(&self) -> PgConnectOptions { + let ssl_mode = if self.require_ssl { + PgSslMode::Require + } else { + PgSslMode::Prefer + }; + + PgConnectOptions::new() + .username(&self.username) + .password(self.password.expose_secret()) + .host(&self.host) + .port(self.port) + .ssl_mode(ssl_mode) } - pub fn connection_string_without_db(&self) -> Secret { - Secret::new(format!( - "postgres://{}:{}@{}:{}", - self.username, - self.password.expose_secret(), - self.host, - self.port - )) + pub fn with_db(&self) -> PgConnectOptions { + let options = self.without_db().database(&self.database_name); + options.log_statements(tracing_log::log::LevelFilter::Trace) } } pub fn get_configuration() -> Result { + let base_path = std::env::current_dir().expect("Failed to determine the current directory"); + let config_dir = base_path.join("configuration"); + let environment: Environment = std::env::var("APP_ENVIRONMENT") + .unwrap_or_else(|_| "local".into()) + .try_into() + .expect("Failed to parse APP_ENVIRONMENT"); + let environment_filename = format!("{}.yaml", environment.as_str()); + let settings = config::Config::builder() - .add_source(config::File::new( - "configuration.yaml", - config::FileFormat::Yaml, - )) + .add_source(config::File::from(config_dir.join("base.yaml"))) + .add_source(config::File::from(config_dir.join(environment_filename))) + // Add in settings from environment variables (with a prefix of APP and + // '__' as separator) + // E.g. `APP_APPLICATION__PORT=5001 would set `Settings.application.port` + .add_source( + config::Environment::with_prefix("APP") + .prefix_separator("_") + .separator("__"), + ) .build()?; settings.try_deserialize::() } + +pub enum Environment { + Local, + Production, +} + +impl Environment { + pub fn as_str(&self) -> &'static str { + match self { + Environment::Local => "local", + Environment::Production => "production", + } + } +} + +impl TryFrom for Environment { + type Error = String; + + fn try_from(value: String) -> Result { + match value.to_lowercase().as_str() { + "local" => Ok(Self::Local), + "production" => Ok(Self::Production), + other => Err(format!( + "{} is not a suppported environment. \ + Use either `local` or `production`.", + other + )), + } + } +} diff --git a/src/main.rs b/src/main.rs index fa8fe7b..3c595f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ -use secrecy::ExposeSecret; -use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; use std::net::TcpListener; use zero2prod::configuration::get_configuration; use zero2prod::startup::run; @@ -11,12 +10,12 @@ async fn main() -> Result<(), std::io::Error> { init_subscriber(subscriber); let configuration = get_configuration().expect("Failed to read configuration"); - let address = format!("127.0.0.1:{}", configuration.application_port); + let address = format!( + "{}:{}", + configuration.application.host, configuration.application.port + ); let listener = TcpListener::bind(address)?; - let connection_pool = - PgPool::connect(configuration.database.connection_string().expose_secret()) - .await - .expect("Failed to connect to Postgres."); + let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); run(listener, connection_pool)?.await } diff --git a/tests/health_check.rs b/tests/health_check.rs index acff3ad..91e5728 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -1,7 +1,6 @@ use std::net::TcpListener; use once_cell::sync::Lazy; -use secrecy::ExposeSecret; use sqlx::{Connection, Executor, PgConnection, PgPool}; use uuid::Uuid; use zero2prod::configuration::{get_configuration, DatabseSettings}; @@ -51,18 +50,17 @@ async fn spawn_app() -> TestApp { async fn configure_database(config: &DatabseSettings) -> PgPool { // Create database - let mut connection = - PgConnection::connect(config.connection_string_without_db().expose_secret()) - .await - .expect("Failed to connect to Postgres."); + let mut connection = PgConnection::connect_with(&config.without_db()) + .await + .expect("Failed to connect to Postgres"); - // Migrate database connection .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()) .await .expect("Failed to create database."); - let connection_pool = PgPool::connect(config.connection_string().expose_secret()) + // Migrate database + let connection_pool = PgPool::connect_with(config.with_db()) .await .expect("Failed to connect to Postgres.");