Skip to content

Commit

Permalink
Feat/alert config (#90)
Browse files Browse the repository at this point in the history
* feat: Add migration to setup alert config tables

* chore: Add local dev seed data for a Slack alert

* feat: Model alert configs

* feat: Add `Error` for invalid alert configs

* chore: Fix `seeder` command in `docker-compose.yml`

* chore: Rename `docker-compose.yml` -> `compose.yaml`

* refactor: `get_connection` out from repos and into free-standing func

* feat: Add data model for reading alert configs

* feat: `AlertConfigRepository` (only reading methods for now)

* refactor: `AlertConfigReadData` -> `AlertConfigData`

* test: Add some more test cases for converting `AlertConfigData` to `AlertConfig`s

* feat: Support inserting, updating and deleting alert configs in `AlertConfigRepository`

* feat: More control over JSON serialisation for `AlertConfig`

* refactor: Test seed deletion

* chore: Use `.contains_key` rather than `.get` and `.is_some`

* test: Add test seeds for alert configs

* test: Add integration tests for `AlertConfigRepository`

* docs: Update `README` with info on env variables required to seed local DB

* chore: Changing warning to note box, and fix formatting

* chore: Try running `clippy` with `--verbose` to get more info

* chore: No need for `--verbose` with `cargo clippy`

* chore: Surpress `clippy` error

* test: Add missing coverage

* chore: Fix failing test
  • Loading branch information
howamith authored Dec 6, 2024
1 parent b54d491 commit afd47d2
Show file tree
Hide file tree
Showing 23 changed files with 1,045 additions and 54 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ api/target

# Miscellaneous
.DS_Store
.env
lcov.info
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,21 @@ intended to be ran within the application container of development container.
Both `Makefile`s have mostly the same commands, with the exception of the following commands that
only the root-level `Makefile` has:

- `install`: Builds all application containers, installs the required Node modules in the Vue
application and sets up a local PostgreSQL database with test data.
- `install`: Builds all application containers and sets up a local PostgreSQL database with test data*.
- `build-containers`: Builds all application containers.
- `seed`: Remove all data from the local database and insert the test data (this is the same test
data that get's written to the local database during `make install`).
data that get's written to the local database during `make install`)*.
- `shell`: Open a `bash` shell on the application container, where you can use the _other_
`Makefile` to run commands without the overhead of spinning up containers for each command.
- `delete-postgres-volume`: Remove the Docker volume being used to make PostgreSQL data persist.
This can be handy is you run into any problems with your local database and you just want to trash
it and start again. The next time the database container runs this will be recreated naturally

> [!NOTE]
> \* - Seeding the local database with test data requires a `.env` file to be present at the root of the project, containing the following environment variables:
> - `SLACK_CHANNEL`: A Slack channel to send Slack alerts to (can be empty if not using Slack integration)
> - `SLACK_TOKEN`: A Slack Bot OAuth token for sending SLack alerts (can be empty if not using Slack integration)
The following commands are present in both `Makefile`s:

- `run`: Run the CronMon API (release build).
Expand Down
129 changes: 129 additions & 0 deletions api/src/domain/models/alert_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use serde::Serialize;
use uuid::Uuid;

/// A domain model representing user configuration for alerts.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct AlertConfig {
/// The unique identifier for the alert configuration.
pub alert_config_id: Uuid,
/// The name of the alert configuration.
pub name: String,
/// The tenant that the alert configuration belongs to.
pub tenant: String,
/// Whether the alert configuration is active.
pub active: bool,
/// Whether to send alerts for late jobs.
pub on_late: bool,
/// Whether to send alerts for errored jobs.
pub on_error: bool,
/// The type of alert.
#[serde(rename = "type")]
pub type_: AlertType,
}

/// The different types of alerts that can be configured.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub enum AlertType {
/// An alert that sends a Slack message.
#[serde(rename = "slack")]
Slack(SlackAlertConfig),
}

/// Slack-specifc configuration for alerts.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SlackAlertConfig {
/// The channel to send the alert to.
pub channel: String,
/// The Slack bot-user OAuth token (for use with chat.postMessage)
pub token: String,
}

impl AlertConfig {
/// Create a new `AlertConfig` for Slack.
pub fn new_slack_config(
name: String,
tenant: String,
active: bool,
on_late: bool,
on_error: bool,
channel: String,
token: String,
) -> Self {
Self {
alert_config_id: Uuid::new_v4(),
name,
tenant,
active,
on_late,
on_error,
type_: AlertType::Slack(SlackAlertConfig { channel, token }),
}
}
}

#[cfg(test)]
mod tests {
use serde_json::json;

use super::*;

#[test]
fn new_slack_config() {
let alert_config = AlertConfig::new_slack_config(
"test-name".to_string(),
"test-tenant".to_string(),
true,
true,
true,
"test-channel".to_string(),
"test-token".to_string(),
);

// Cannot check the alert_config_id as it is randomly generated, but we know it'll be a Uuid
// because of its type.
assert_eq!(&alert_config.name, "test-name");
assert_eq!(&alert_config.tenant, "test-tenant");
assert!(alert_config.active);
assert!(alert_config.on_late);
assert!(alert_config.on_error);
assert_eq!(
alert_config.type_,
AlertType::Slack(SlackAlertConfig {
channel: "test-channel".to_string(),
token: "test-token".to_string(),
})
);
}

#[test]
fn test_serialisation() {
let alert_config = AlertConfig::new_slack_config(
"test-name".to_string(),
"test-tenant".to_string(),
true,
true,
true,
"test-channel".to_string(),
"test-token".to_string(),
);

let value = serde_json::to_value(&alert_config).unwrap();
assert_eq!(
value,
json!({
"alert_config_id": alert_config.alert_config_id.to_string(),
"name": "test-name",
"tenant": "test-tenant",
"active": true,
"on_late": true,
"on_error": true,
"type": {
"slack": {
"channel": "test-channel",
"token": "test-token"
}
}
})
);
}
}
1 change: 1 addition & 0 deletions api/src/domain/models/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod alert_config;
pub mod api_key;
pub mod job;
pub mod monitor;
2 changes: 2 additions & 0 deletions api/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub enum Error {
JobAlreadyFinished(Uuid),
InvalidMonitor(String),
InvalidJob(String),
InvalidAlertConfig(String),
Unauthorized(String),
AuthenticationError(String),
}
Expand All @@ -37,6 +38,7 @@ impl Display for Error {
}
Self::InvalidMonitor(reason) => write!(f, "Invalid Monitor: {reason}"),
Self::InvalidJob(reason) => write!(f, "Invalid Job: {reason}"),
Self::InvalidAlertConfig(reason) => write!(f, "Invalid Alert Configuration: {reason}"),
Self::Unauthorized(reason) => write!(f, "Unauthorized: {reason}"),
Self::AuthenticationError(reason) => write!(f, "Authentication error: {reason}"),
}
Expand Down
11 changes: 10 additions & 1 deletion api/src/infrastructure/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::env;

use diesel::Connection;
use diesel::PgConnection;
use diesel_async::pooled_connection::{deadpool::Pool, AsyncDieselConnectionManager};
use diesel_async::pooled_connection::{
deadpool::{Object, Pool},
AsyncDieselConnectionManager,
};
use diesel_async::AsyncPgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};

Expand All @@ -22,6 +25,12 @@ pub fn create_connection_pool() -> Result<DbPool, Error> {
Ok(pool)
}

pub async fn get_connection(pool: &DbPool) -> Result<Object<AsyncPgConnection>, Error> {
pool.get()
.await
.map_err(|e| Error::RepositoryError(e.to_string()))
}

pub fn run_migrations() {
let mut conn = PgConnection::establish(&get_database_url())
.unwrap_or_else(|_| panic!("Failed to establish DB connection"));
Expand Down
44 changes: 43 additions & 1 deletion api/src/infrastructure/db_schema.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
// @generated automatically by Diesel CLI.

diesel::table! {
alert_config (alert_config_id) {
alert_config_id -> Uuid,
created_at -> Timestamp,
updated_at -> Timestamp,
name -> Varchar,
tenant -> Varchar,
#[sql_name = "type"]
type_ -> Varchar,
active -> Bool,
on_late -> Bool,
on_error -> Bool,
}
}

diesel::table! {
api_key (api_key_id) {
api_key_id -> Uuid,
Expand Down Expand Up @@ -43,6 +58,33 @@ diesel::table! {
}
}

diesel::table! {
monitor_alert_config (alert_config_id, monitor_id) {
alert_config_id -> Uuid,
monitor_id -> Uuid,
}
}

diesel::table! {
slack_alert_config (alert_config_id) {
alert_config_id -> Uuid,
created_at -> Timestamp,
updated_at -> Timestamp,
slack_channel -> Varchar,
slack_bot_oauth_token -> Varchar,
}
}

diesel::joinable!(job -> monitor (monitor_id));
diesel::joinable!(monitor_alert_config -> alert_config (alert_config_id));
diesel::joinable!(monitor_alert_config -> monitor (monitor_id));
diesel::joinable!(slack_alert_config -> alert_config (alert_config_id));

diesel::allow_tables_to_appear_in_same_query!(api_key, job, monitor,);
diesel::allow_tables_to_appear_in_same_query!(
alert_config,
api_key,
job,
monitor,
monitor_alert_config,
slack_alert_config,
);
29 changes: 29 additions & 0 deletions api/src/infrastructure/middleware/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ impl<'r> Responder<'r, 'static> for Error {
// default to server-side errors.
Error::InvalidMonitor(_) => (Status::InternalServerError, "Invalid Monitor"),
Error::InvalidJob(_) => (Status::InternalServerError, "Invalid Job"),
Error::InvalidAlertConfig(_) => {
(Status::InternalServerError, "Invalid Alert Configuration")
}
Error::Unauthorized(_) => (Status::Unauthorized, "Unauthorized"),
Error::AuthenticationError(_) => (Status::InternalServerError, "Authentication Error"),
};
Expand Down Expand Up @@ -91,6 +94,13 @@ mod tests {
Err(Error::InvalidJob("invalid job".to_string()))
}

#[rocket::get("/invalid_alert_config")]
fn invalid_alert_config() -> Result<(), Error> {
Err(Error::InvalidAlertConfig(
"invalid alert config".to_string(),
))
}

#[rocket::get("/unauthorized")]
fn unauthorized() -> Result<(), Error> {
Err(Error::Unauthorized("insufficient permissions".to_string()))
Expand All @@ -115,6 +125,7 @@ mod tests {
job_already_finished,
invalid_monitor,
invalid_job,
invalid_alert_config,
unauthorized,
auth_error
],
Expand Down Expand Up @@ -253,6 +264,24 @@ mod tests {
);
}

#[rstest]
fn test_invalid_alert_config(test_client: Client) {
let response = test_client.get("/invalid_alert_config").dispatch();

assert_eq!(response.status(), Status::InternalServerError);
assert_eq!(response.content_type(), Some(ContentType::JSON));
assert_eq!(
response.into_json::<Value>().unwrap(),
json!({
"error": {
"code": 500,
"reason": "Invalid Alert Configuration",
"description": "Invalid Alert Configuration: invalid alert config"
}
})
);
}

#[rstest]
fn test_unauthorized(test_client: Client) {
let response = test_client.get("/unauthorized").dispatch();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP TABLE monitor_alert_config;
DROP TABLE slack_alert_config;
DROP TABLE alert_config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
CREATE TABLE alert_config (
alert_config_id uuid PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

name VARCHAR NOT NULL,
tenant VARCHAR NOT NULL,
type VARCHAR NOT NULL,
active BOOLEAN NOT NULL,
on_late BOOLEAN NOT NULL,
on_error BOOLEAN NOT NULL
);

CREATE TABLE slack_alert_config (
alert_config_id uuid PRIMARY KEY REFERENCES alert_config ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

-- These columns are intentionally overly verbose in how they are named,
-- since we're using class table inheritance, and we fetch all alert configs
-- in one query using left joins. If we weren't as verbose here, the columns
-- could class (for example if we add support for Discord alerts, would
-- 'channel' be for Discord or for Slack?
slack_channel VARCHAR NOT NULL,
slack_bot_oauth_token VARCHAR NOT NULL
);

-- This is an association table between alert_config and monitor.
CREATE TABLE monitor_alert_config (
alert_config_id uuid REFERENCES alert_config ON DELETE CASCADE,
monitor_id uuid REFERENCES monitor ON DELETE CASCADE,

CONSTRAINT pk_monitor_alert_config PRIMARY KEY (alert_config_id, monitor_id)
);

SELECT diesel_manage_updated_at('alert_config');
SELECT diesel_manage_updated_at('slack_alert_config');
Loading

0 comments on commit afd47d2

Please sign in to comment.