// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Repository mirrors: outbound (push to a remote) and inbound (pull from a //! remote). The periodic sync loop reads [`Store::due_mirrors`]; a received push //! triggers [`Store::push_mirrors_on_push`]. Credentials are stored reversibly //! (a mirror must authenticate to its remote) — prefer a scoped token. use sqlx::Row; use sqlx::any::AnyRow; use crate::{Store, StoreError, new_id, now_ms}; /// Whether a mirror pushes to, or pulls from, its remote. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MirrorDirection { /// Push the repo's refs out to the remote. Push, /// Pull the remote's refs into the repo. Pull, } impl MirrorDirection { /// The stored token. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Push => "push", Self::Pull => "pull", } } /// Parse a stored token. #[must_use] pub fn parse(s: &str) -> Self { if s == "pull" { Self::Pull } else { Self::Push } } } /// A configured mirror. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Mirror { /// ULID primary key. pub id: String, /// The repository this mirror belongs to. pub repo_id: String, /// Direction (push / pull). pub direction: MirrorDirection, /// The remote git URL. pub remote_url: String, /// Optional credential username. pub username: Option, /// Optional credential password/token. pub secret: Option, /// Optional branch filter (push only); blank means all branches. pub branch_filter: Option, /// Sync interval in seconds; `0` means manual only. pub interval_secs: i64, /// Push mirrors: also sync right after a received push. pub sync_on_push: bool, /// When the last successful sync completed. pub last_sync_at: Option, /// The last sync error, if the most recent attempt failed. pub last_error: Option, /// Creation time. pub created_at: i64, } /// Fields to create a mirror. #[derive(Debug, Clone)] pub struct NewMirror { /// The repository this mirror belongs to. pub repo_id: String, /// Direction (push / pull). pub direction: MirrorDirection, /// The remote git URL. pub remote_url: String, /// Optional credential username. pub username: Option, /// Optional credential password/token. pub secret: Option, /// Optional branch filter (push only). pub branch_filter: Option, /// Sync interval in seconds (`0` = manual). pub interval_secs: i64, /// Push mirrors: sync after a received push. pub sync_on_push: bool, } /// Columns of `mirrors`, in a fixed order shared by every `SELECT`. const MIRROR_COLUMNS: &str = "id, repo_id, direction, remote_url, username, secret, \ branch_filter, interval_secs, sync_on_push, last_sync_at, last_error, created_at"; impl Store { /// Map a `mirrors` row. fn map_mirror(&self, row: &AnyRow) -> Result { Ok(Mirror { id: row.try_get("id")?, repo_id: row.try_get("repo_id")?, direction: MirrorDirection::parse(&row.try_get::("direction")?), remote_url: row.try_get("remote_url")?, username: row.try_get("username")?, secret: row.try_get("secret")?, branch_filter: row.try_get("branch_filter")?, interval_secs: row.try_get("interval_secs")?, sync_on_push: self.get_bool(row, "sync_on_push")?, last_sync_at: row.try_get("last_sync_at")?, last_error: row.try_get("last_error")?, created_at: row.try_get("created_at")?, }) } /// Create a mirror. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn create_mirror(&self, new: NewMirror) -> Result { let mirror = Mirror { id: new_id(), repo_id: new.repo_id, direction: new.direction, remote_url: new.remote_url, username: new.username, secret: new.secret, branch_filter: new.branch_filter, interval_secs: new.interval_secs, sync_on_push: new.sync_on_push, last_sync_at: None, last_error: None, created_at: now_ms(), }; sqlx::query( "INSERT INTO mirrors \ (id, repo_id, direction, remote_url, username, secret, branch_filter, \ interval_secs, sync_on_push, last_sync_at, last_error, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", ) .bind(&mirror.id) .bind(&mirror.repo_id) .bind(mirror.direction.as_str()) .bind(&mirror.remote_url) .bind(&mirror.username) .bind(&mirror.secret) .bind(&mirror.branch_filter) .bind(mirror.interval_secs) .bind(mirror.sync_on_push) .bind(mirror.last_sync_at) .bind(&mirror.last_error) .bind(mirror.created_at) .execute(&self.pool) .await?; Ok(mirror) } /// List a repository's mirrors, newest first. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn mirrors_for_repo(&self, repo_id: &str) -> Result, StoreError> { let sql = format!( "SELECT {MIRROR_COLUMNS} FROM mirrors WHERE repo_id = $1 ORDER BY created_at DESC" ); let rows = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(repo_id) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_mirror(r)) .collect::>() .map_err(Into::into) } /// Fetch a mirror by id. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn mirror_by_id(&self, id: &str) -> Result, StoreError> { let sql = format!("SELECT {MIRROR_COLUMNS} FROM mirrors WHERE id = $1"); let row = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(id) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_mirror(r)).transpose()?) } /// Delete a mirror by id. Returns whether a row was removed. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_mirror(&self, id: &str) -> Result { let n = sqlx::query("DELETE FROM mirrors WHERE id = $1") .bind(id) .execute(&self.pool) .await? .rows_affected(); Ok(n > 0) } /// Mirrors whose periodic sync is due at `now` (interval elapsed, or never /// synced), across every repo. `interval_secs = 0` mirrors are excluded. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn due_mirrors(&self, now_ms: i64) -> Result, StoreError> { let sql = format!( "SELECT {MIRROR_COLUMNS} FROM mirrors \ WHERE interval_secs > 0 \ AND (last_sync_at IS NULL OR last_sync_at + interval_secs * 1000 <= $1) \ ORDER BY created_at ASC" ); let rows = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(now_ms) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_mirror(r)) .collect::>() .map_err(Into::into) } /// Push mirrors for `repo_id` that should sync after a received push. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn push_mirrors_on_push(&self, repo_id: &str) -> Result, StoreError> { let sql = format!( "SELECT {MIRROR_COLUMNS} FROM mirrors \ WHERE repo_id = $1 AND direction = 'push' AND sync_on_push = $2" ); let rows = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(repo_id) .bind(true) .fetch_all(&self.pool) .await?; rows.iter() .map(|r| self.map_mirror(r)) .collect::>() .map_err(Into::into) } /// Record the outcome of a sync attempt: on success clear the error and stamp /// `last_sync_at`; on failure store the error message. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn mirror_synced(&self, id: &str, error: Option<&str>) -> Result<(), StoreError> { sqlx::query("UPDATE mirrors SET last_sync_at = $1, last_error = $2 WHERE id = $3") .bind(now_ms()) .bind(error) .bind(id) .execute(&self.pool) .await?; Ok(()) } }