diff --git a/.github/workflows/ci-check-nym-stats-api-version.yml b/.github/workflows/ci-check-nym-stats-api-version.yml new file mode 100644 index 0000000000..2bcb0bc87e --- /dev/null +++ b/.github/workflows/ci-check-nym-stats-api-version.yml @@ -0,0 +1,57 @@ +name: ci-check-nym-stats-api-version + +on: + pull_request: + paths: + - "nym-statistics-api/**" + +env: + WORKING_DIRECTORY: "nym-statistics-api" + +jobs: + check-if-tag-exists: + runs-on: arc-ubuntu-22.04-dind + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Get version from cargo.toml + uses: mikefarah/yq@v4.45.4 + id: get_version + with: + cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml + + - name: Check if git tag exists + run: | + TAG=${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + if [[ -z "$TAG" ]]; then + echo "Tag is empty" + exit 1 + fi + git ls-remote --tags origin | awk '{print $2}' + if git ls-remote --tags origin | awk '{print $2}' | grep -q "refs/tags/$TAG$" ; then + echo "Tag '$TAG' ALREADY EXISTS on the remote" + exit 1 + else + echo "Tag '$TAG' does not exist on the remote" + fi + - name: Check if harbor tag exists + run: | + TAG=${{ steps.get_version.outputs.result }} + registry=https://harbor.nymte.ch + repo_name=nym/nym-statistics-api + if [[ -z $TAG ]]; then + echo "Tag is empty" + exit 1 + fi + curl -su ${{ secrets.HARBOR_ROBOT_USERNAME }}:${{ secrets.HARBOR_ROBOT_SECRET }} "$registry/v2/$repo_name/tags/list" | jq + exists=$(curl -su ${{ secrets.HARBOR_ROBOT_USERNAME }}:${{ secrets.HARBOR_ROBOT_SECRET }} "$registry/v2/$repo_name/tags/list" | jq --arg tag $TAG '.tags | contains([$tag])' ) + if [[ $exists = "true" ]]; then + echo "Version '$TAG' defined in Cargo.toml ALREADY EXISTS as tag in harbor repo" + exit 1 + elif [[ $exists = "false" ]]; then + echo "Version '$TAG' doesn't exist on the remote" + else + echo "Unknown output '$exists'" + exit 1 + fi diff --git a/.github/workflows/push-nym-statistics-api.yaml b/.github/workflows/push-nym-statistics-api.yaml new file mode 100644 index 0000000000..4dd78acc60 --- /dev/null +++ b/.github/workflows/push-nym-statistics-api.yaml @@ -0,0 +1,42 @@ +name: Build and upload Nym Statistics API container to harbor.nymte.ch +on: + workflow_dispatch: + +env: + WORKING_DIRECTORY: "nym-statistics-api" + CONTAINER_NAME: "nym-statistics-api" + +jobs: + build-container: + runs-on: arc-ubuntu-22.04-dind + steps: + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.nymte.ch + username: ${{ secrets.HARBOR_ROBOT_USERNAME }} + password: ${{ secrets.HARBOR_ROBOT_SECRET }} + + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Configure git identity + run: | + git config --global user.email "lawrence@nymtech.net" + git config --global user.name "Lawrence Stalder" + + - name: Get version from cargo.toml + uses: mikefarah/yq@v4.45.4 + id: get_version + with: + cmd: yq -oy '.package.version' ${{ env.WORKING_DIRECTORY }}/Cargo.toml + + - name: Create tag + run: | + git tag -a ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} -m "Version ${{ steps.get_version.outputs.result }}" + git push origin ${{ env.WORKING_DIRECTORY }}-${{ steps.get_version.outputs.result }} + + - name: BuildAndPushImageOnHarbor + run: | + docker build -f ${{ env.WORKING_DIRECTORY }}/Dockerfile . -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:${{ steps.get_version.outputs.result }} -t harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }}:latest + docker push harbor.nymte.ch/nym/${{ env.CONTAINER_NAME }} --all-tags diff --git a/nym-statistics-api/.gitignore b/nym-statistics-api/.gitignore new file mode 100644 index 0000000000..ba051bb2e8 --- /dev/null +++ b/nym-statistics-api/.gitignore @@ -0,0 +1,2 @@ +*.pem +.env* diff --git a/nym-statistics-api/Dockerfile b/nym-statistics-api/Dockerfile new file mode 100644 index 0000000000..48f6449048 --- /dev/null +++ b/nym-statistics-api/Dockerfile @@ -0,0 +1,34 @@ +# this will only work with VPN, otherwise remove the harbor part +FROM harbor.nymte.ch/dockerhub/rust:latest AS builder + +COPY ./ /usr/src/nym +WORKDIR /usr/src/nym/nym-statistics-api + +RUN cargo build --release + + +#------------------------------------------------------------------- +# The following environment variables are required at runtime: +# +# DATABASE_URL +# POSTGRES_USER +# POSTGRES_PASSWORD +# PG_SSL_CERT +# +# And optionally: +# +# NYM_API_URL +# NYM_STATISTICS_API_HTTP_PORT +# PGPORT +# +# see https://github.com/nymtech/nym/blob/develop/nym-statistics-api/src/cli/mod.rs for details +#------------------------------------------------------------------- + +FROM harbor.nymte.ch/dockerhub/ubuntu:24.04 + +RUN apt-get update && apt-get install -y ca-certificates + +WORKDIR /nym + +COPY --from=builder /usr/src/nym/target/release/nym-statistics-api ./ +ENTRYPOINT [ "/nym/nym-statistics-api" ] diff --git a/nym-statistics-api/launch_statistics_api.sh b/nym-statistics-api/launch_statistics_api.sh new file mode 100755 index 0000000000..1ec650f99e --- /dev/null +++ b/nym-statistics-api/launch_statistics_api.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +user_rust_log_preference=$RUST_LOG + + +function run_bare() { + # export necessary env vars + set -a + source .env + set +a + export RUST_LOG=${user_rust_log_preference:-debug} + echo "RUST_LOG=${RUST_LOG}" + + cargo run --package nym-statistics-api +} + + +# Requires pg_up.sh, or a running postres instance, with the correct parameters in an .env file +run_bare diff --git a/nym-statistics-api/pg_up.sh b/nym-statistics-api/pg_up.sh index 963efb6a91..db84118209 100755 --- a/nym-statistics-api/pg_up.sh +++ b/nym-statistics-api/pg_up.sh @@ -16,15 +16,22 @@ DB_NAME=$DB_NAME DATABASE_URL=$DATABASE_URL EOF +cat < init_schema.sql +CREATE SCHEMA private_statistics_api; +EOF + docker run --rm -it \ --name ${DB_NAME} \ -e POSTGRES_USER=${PGUSER} \ -e POSTGRES_PASSWORD=${PGPASSWORD} \ -e POSTGRES_DB=${DB_NAME} \ + -v $(pwd)/init_schema.sql:/docker-entrypoint-initdb.d/init_schema.sql \ -p ${PGPORT}:${PGPORT} \ postgres +rm init_schema.sql + # sqlx migrate run # cargo sqlx prepare \ No newline at end of file diff --git a/nym-statistics-api/src/cli/mod.rs b/nym-statistics-api/src/cli/mod.rs index 5a8373fa4a..528d552e65 100644 --- a/nym-statistics-api/src/cli/mod.rs +++ b/nym-statistics-api/src/cli/mod.rs @@ -1,6 +1,6 @@ use clap::Parser; use nym_bin_common::bin_info; -use std::sync::OnceLock; +use std::{path::PathBuf, sync::OnceLock}; use url::Url; // Helper for passing LONG_VERSION to clap @@ -35,4 +35,8 @@ pub(crate) struct Cli { /// PgSQL port for the database. #[clap(long, default_value_t = 5432, env = "PGPORT")] pub(crate) pg_port: u16, + + /// SSL root certificate path. Providing this will also set the ssl mode to `Require` + #[clap(long, env = "PG_SSL_CERT")] + pub(crate) ssl_cert_path: Option, } diff --git a/nym-statistics-api/src/main.rs b/nym-statistics-api/src/main.rs index f08896dbc5..069b547e5e 100644 --- a/nym-statistics-api/src/main.rs +++ b/nym-statistics-api/src/main.rs @@ -22,6 +22,7 @@ async fn main() -> anyhow::Result<()> { args.username, args.password, args.pg_port, + args.ssl_cert_path, ) .await?; tracing::info!("Connection to database successful"); diff --git a/nym-statistics-api/src/storage/mod.rs b/nym-statistics-api/src/storage/mod.rs index fe14eedbea..4ea9bad118 100644 --- a/nym-statistics-api/src/storage/mod.rs +++ b/nym-statistics-api/src/storage/mod.rs @@ -1,12 +1,18 @@ use anyhow::{anyhow, Result}; use models::{ConnectionInfoDto, DailyActiveDeviceDto}; -use sqlx::{migrate::Migrator, postgres::PgConnectOptions}; -use std::str::FromStr; +use sqlx::{ + migrate::Migrator, + postgres::{PgConnectOptions, PgPoolOptions}, + Executor, +}; +use std::{path::PathBuf, str::FromStr}; pub(crate) mod models; pub(crate) type DbPool = sqlx::PgPool; + static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); +const SET_SEARCH_PATH: &str = "SET search_path = private_statistics_api"; #[derive(Debug, Clone)] pub(crate) struct StatisticsStorage { @@ -19,14 +25,31 @@ impl StatisticsStorage { user: String, password: String, port: u16, + ssl_cert_path: Option, ) -> Result { - let connect_options = PgConnectOptions::from_str(&connection_url)? + let mut connect_options = PgConnectOptions::from_str(&connection_url)? .port(port) .username(&user) .password(&password) .application_name(nym_bin_common::bin_info!().binary_name); - let pool = sqlx::PgPool::connect_with(connect_options) + if let Some(ssl_cert) = ssl_cert_path { + connect_options = connect_options + .ssl_mode(sqlx::postgres::PgSslMode::Require) + .ssl_root_cert(ssl_cert); + } + + // This is a custom connection so the _sqlx_migrations table is not written in the public schema + // It then ensures we'll only write in the given schema, allowing to have the schema name only once here + // Ref : https://github.com/launchbadge/sqlx/issues/1835 + let pool = PgPoolOptions::new() + .after_connect(|conn, _meta| { + Box::pin(async move { + conn.execute(SET_SEARCH_PATH).await?; + Ok(()) + }) + }) + .connect_with(connect_options) .await .map_err(|err| anyhow!("Failed to connect to {}: {}", &connection_url, err))?; @@ -49,9 +72,6 @@ impl StatisticsStorage { Ok(()) } - // Interestingly enough, because gateway-storage is using the `chrono` feature of sqlx and in 0.7.4 it takes priority over the `time` one, we cannot use the query! macro here. - // Due to features unification, the binary will not compile when built from the workspace root because it will expect `chrono` types. - // As a consequence, there is no compile time verification of these queries. async fn store_device(&self, active_device: DailyActiveDeviceDto) -> Result<()> { sqlx::query!( r#"INSERT INTO active_device (