Complete Chapter 5

+ Add in More CI/CD
+ Update Tests and Configurations
This commit is contained in:
Nick Bland 2024-03-18 13:56:21 +10:00
parent c88d909639
commit ba4c7ccf64
14 changed files with 225 additions and 74 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.env
target/
tests/
Dockerfile
scripts/
migrations/

View File

@ -1,50 +1,57 @@
default: image: "rust:latest"
before_script:
- rustc --version && cargo --version
image: "registry.nickbland.dev/nickbland/rust-docker-ci:latest"
cache:
key:
files:
- Cargo.lock
paths:
- .cargo/
- target/
policy: pull-push
variables:
FF_USE_FASTZIP: "true"
CACHE_COMPRESSION_LEVEL: "fastest"
stages:
- format
- test
format-code:
stage: format
script:
- cargo fmt -- --check
postgres-service:
stage: test
services: services:
- postgres:15-alpine - postgres:latest
variables: variables:
POSTGRES_DB: newsletter
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: password
POSTGRES_DB: newsletter
POSTGRES_HOST: postgres POSTGRES_HOST: postgres
POSTGRES_PORT: 5432 DB_PORT: 5432
DATABASE_URL: "postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$DB_PORT/$POSTGRES_DB"
APP_DATABASE__HOST: $POSTGRES_HOST
cache: # Caches build artifacts so we don't build from scratch in both build and test
key: ${CI_COMMIT_REF_SLUG}
paths:
- .cargo/bin
- .cargo/registry/index
- .cargo/registry/cache
- target/debug/deps
- target/debug/build
policy: pull-push
default:
before_script:
- export CARGO_HOME="$CI_PROJECT_DIR/.cargo"
- export PATH="$CARGO_HOME/bin:$PATH"
- rustc --version
- cargo --version
- if ! [ -x "$(command -v cargo-sqlx)" ]; then cargo install --version='~0.7' sqlx-cli --no-default-features --features rustls,postgres; fi
- apt update -yq && apt-get install -yq postgresql-client
- SKIP_DOCKER=true ./scripts/init_db.sh
# This is to ensure that the database is reachable and give it some time to initialize.
- until psql "dbname=$POSTGRES_DB user=$POSTGRES_USER password=$POSTGRES_PASSWORD host=postgres" -c '\l'; do sleep 3; done
stages:
- build
- test
build:
stage: build
script: script:
- SKIP_DOCKER=true ./scripts/init_db.sh # Migrate DB - cargo build
test-code: test-code:
stage: test stage: test
needs: ["postgres-service"]
script: script:
- export DATABASE_URL="postgres://postgres:password@postgres:5432/newsletter" cargo test - cargo test
- if ! [ -x "$(command -v cargo-tarpaulin)" ]; then cargo install cargo-tarpaulin; fi
- cargo tarpaulin --ignore-tests
lint-code: lint-code:
stage: test stage: test
needs: ["postgres-service"]
script: script:
- DATABASE_URL="postgres://postgres:password@postgres:5432/newsletter" cargo clippy -- -D warnings - rustup component add clippy
- cargo clippy -- -D warnings

View File

@ -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": "bcfcfebc6f5e8ffbf97d97c5a209be78b46d703924482cf8b43842705fcb7714"
}

12
Cargo.lock generated
View File

@ -1164,6 +1164,7 @@ dependencies = [
"reqwest", "reqwest",
"secrecy", "secrecy",
"serde", "serde",
"serde-aux",
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
@ -1849,6 +1850,17 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.188" version = "1.0.188"

View File

@ -14,6 +14,7 @@ name = "mail_app"
actix-web = "4" actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde-aux = "4"
config = { version = "0.13", default-features = false, features = ["yaml"] } config = { version = "0.13", default-features = false, features = ["yaml"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4.22", default-features = false, features = ["clock"] } chrono = { version = "0.4.22", default-features = false, features = ["clock"] }

32
Dockerfile Normal file
View File

@ -0,0 +1,32 @@
##### Chef
FROM lukemathwalker/cargo-chef:latest-rust-1.76.0 as chef
WORKDIR /app
RUN apt update && apt install lld clang -y
##### Planner
FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
##### Builder
# Builder prepares project dependancies, not the application.
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
ENV SQLX_OFFLINE true
# Now build the application itself.
RUN cargo build --release --bin mail_app
##### Runtime
FROM debian:bookworm-slim as runtime
WORKDIR /app
RUN apt update && apt install -y --no-install-recommends openssl ca-certificates \
&& apt autoremove -y \
&& apt clean -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/mail_app mail_app
COPY configuration configuration
ENV APP_ENVIRONMENT production
ENTRYPOINT ["./mail_app"]

View File

@ -1,7 +0,0 @@
application_port: 8000
database:
host: "127.0.0.1"
port: 5432
username: "postgres"
password: "password"
database_name: "newsletter"

8
configuration/base.yaml Normal file
View File

@ -0,0 +1,8 @@
application:
port: 8000
database:
host: "localhost"
port: 5432
username: "postgres"
password: "password"
database_name: "newsletter"

4
configuration/local.yaml Normal file
View File

@ -0,0 +1,4 @@
application:
host: 127.0.0.1
database:
require_ssl: false

View File

@ -0,0 +1,4 @@
application:
host: 0.0.0.0
database:
require_ssl: true

14
scripts/remove_test_dbs.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -x
set -eo pipefail
DB_USER="${POSTGRES_USER:=postgres}"
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
DB_NAME="${POSTGRES_DB:=newsletter}"
DB_PORT="${POSTGRES_PORT:=5432}"
DB_HOST="${POSTGRES_HOST:=localhost}"
for dbname in $(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "copy (select datname from pg_database where datname like '%-%-%-%-%') to stdout") ; do
echo "dropping database $dbname"
dropdb -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" "$dbname"
done

View File

@ -1,50 +1,105 @@
use secrecy::{ExposeSecret, Secret}; use secrecy::{ExposeSecret, Secret};
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::postgres::{PgConnectOptions, PgSslMode};
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct Settings { pub struct Settings {
pub database: DatabaseSettings, pub database: DatabaseSettings,
pub application_port: u16, pub application: ApplicationSettings,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct DatabaseSettings { pub struct DatabaseSettings {
pub username: String, pub username: String,
pub password: Secret<String>, pub password: Secret<String>,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16, pub port: u16,
pub host: String, pub host: String,
pub database_name: String, pub database_name: String,
pub require_ssl: bool,
}
#[derive(serde::Deserialize)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
}
pub enum Environment {
Local,
Production,
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Environment::Local),
"production" => Ok(Environment::Production),
e => Err(format!(
"{} is not a supported environment. Use `local` or `production`",
e
)),
}
}
} }
pub fn get_configuration() -> Result<Settings, config::ConfigError> { pub fn get_configuration() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let configuration_directory = base_path.join("configuration");
// Detect current environment, default to LOCAL
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());
// initialise config reader // initialise config reader
let settings = config::Config::builder() let settings = config::Config::builder()
.add_source(config::File::new( .add_source(config::File::from(
"configuration.yaml", configuration_directory.join("base.yaml"),
config::FileFormat::Yaml,
)) ))
.add_source(config::File::from(
configuration_directory.join(environment_filename),
))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?; .build()?;
settings.try_deserialize::<Settings>() settings.try_deserialize::<Settings>()
} }
impl DatabaseSettings { impl DatabaseSettings {
pub fn connection_string(&self) -> Secret<String> { pub fn without_db(&self) -> PgConnectOptions {
Secret::new(format!( let ssl_mode = if self.require_ssl {
"postgres://{}:{}@{}:{}/{}", PgSslMode::Require
self.username, } else {
self.password.expose_secret(), PgSslMode::Prefer
self.host, };
self.port, PgConnectOptions::new()
self.database_name .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<String> { pub fn with_db(&self) -> PgConnectOptions {
Secret::new(format!( self.without_db().database(&self.database_name)
"postgres://{}:{}@{}:{}",
self.username,
self.password.expose_secret(),
self.host,
self.port
))
} }
} }

View File

@ -1,8 +1,7 @@
use mail_app::configuration::get_configuration; use mail_app::configuration::get_configuration;
use mail_app::startup::run; use mail_app::startup::run;
use mail_app::telemetry::{get_subscriber, init_subscriber}; use mail_app::telemetry::{get_subscriber, init_subscriber};
use secrecy::ExposeSecret; use sqlx::postgres::PgPoolOptions;
use sqlx::postgres::PgPool;
use std::net::TcpListener; use std::net::TcpListener;
#[tokio::main] #[tokio::main]
@ -11,11 +10,11 @@ async fn main() -> Result<(), std::io::Error> {
init_subscriber(subscriber); init_subscriber(subscriber);
let configuration = get_configuration().expect("Failed to read configuration"); let configuration = get_configuration().expect("Failed to read configuration");
let connection_pool = let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
PgPool::connect(&configuration.database.connection_string().expose_secret()) let address = format!(
.await "{}:{}",
.expect("Failed to connect to Postgres."); configuration.application.host, configuration.application.port
let address = format!("127.0.0.1:{}", configuration.application_port); );
let listener = TcpListener::bind(address)?; let listener = TcpListener::bind(address)?;
run(listener, connection_pool)?.await run(listener, connection_pool)?.await
} }

View File

@ -2,8 +2,7 @@ use mail_app::configuration::{get_configuration, DatabaseSettings};
use mail_app::startup::run; use mail_app::startup::run;
use mail_app::telemetry::{get_subscriber, init_subscriber}; use mail_app::telemetry::{get_subscriber, init_subscriber};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use secrecy::ExposeSecret; use sqlx::{Connection, Executor, PgConnection, PgPool};
use sqlx::{Executor, PgPool};
use std::net::TcpListener; use std::net::TcpListener;
use uuid::Uuid; use uuid::Uuid;
@ -47,7 +46,7 @@ async fn spawn_app() -> TestApp {
pub async fn configure_database(config: &DatabaseSettings) -> PgPool { pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
// Create Database // Create Database
let connection = PgPool::connect(&config.connection_string_without_db().expose_secret()) let mut connection = PgConnection::connect_with(&config.without_db())
.await .await
.expect("Failed to connect to Postgres."); .expect("Failed to connect to Postgres.");
connection connection
@ -56,7 +55,7 @@ pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
.expect("Failed to create database."); .expect("Failed to create database.");
// Migrate Database // Migrate Database
let connection_pool = PgPool::connect(&config.connection_string().expose_secret()) let connection_pool = PgPool::connect_with(config.with_db())
.await .await
.expect("Failed to connect to Postgres."); .expect("Failed to connect to Postgres.");
sqlx::migrate!("./migrations") sqlx::migrate!("./migrations")