Complete Chapter 5
+ Add in More CI/CD + Update Tests and Configurations
This commit is contained in:
parent
c88d909639
commit
ba4c7ccf64
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
target/
|
||||||
|
tests/
|
||||||
|
Dockerfile
|
||||||
|
scripts/
|
||||||
|
migrations/
|
@ -1,50 +1,57 @@
|
|||||||
default:
|
image: "rust:latest"
|
||||||
before_script:
|
|
||||||
- rustc --version && cargo --version
|
services:
|
||||||
image: "registry.nickbland.dev/nickbland/rust-docker-ci:latest"
|
- postgres:latest
|
||||||
cache:
|
|
||||||
key:
|
|
||||||
files:
|
|
||||||
- Cargo.lock
|
|
||||||
paths:
|
|
||||||
- .cargo/
|
|
||||||
- target/
|
|
||||||
policy: pull-push
|
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
FF_USE_FASTZIP: "true"
|
POSTGRES_DB: newsletter
|
||||||
CACHE_COMPRESSION_LEVEL: "fastest"
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
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:
|
stages:
|
||||||
- format
|
- build
|
||||||
- test
|
- test
|
||||||
|
|
||||||
format-code:
|
build:
|
||||||
stage: format
|
stage: build
|
||||||
script:
|
script:
|
||||||
- cargo fmt -- --check
|
- cargo build
|
||||||
|
|
||||||
postgres-service:
|
|
||||||
stage: test
|
|
||||||
services:
|
|
||||||
- postgres:15-alpine
|
|
||||||
variables:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: password
|
|
||||||
POSTGRES_DB: newsletter
|
|
||||||
POSTGRES_HOST: postgres
|
|
||||||
POSTGRES_PORT: 5432
|
|
||||||
script:
|
|
||||||
- SKIP_DOCKER=true ./scripts/init_db.sh # Migrate DB
|
|
||||||
|
|
||||||
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
|
||||||
|
@ -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
12
Cargo.lock
generated
@ -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"
|
||||||
|
@ -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
32
Dockerfile
Normal 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"]
|
@ -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
8
configuration/base.yaml
Normal 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
4
configuration/local.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
application:
|
||||||
|
host: 127.0.0.1
|
||||||
|
database:
|
||||||
|
require_ssl: false
|
4
configuration/production.yaml
Normal file
4
configuration/production.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
application:
|
||||||
|
host: 0.0.0.0
|
||||||
|
database:
|
||||||
|
require_ssl: true
|
14
scripts/remove_test_dbs.sh
Executable file
14
scripts/remove_test_dbs.sh
Executable 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
|
@ -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
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
13
src/main.rs
13
src/main.rs
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
Loading…
Reference in New Issue
Block a user