Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ jobs:
FORGUST_SERVER__LISTEN: 127.0.0.1:18491
FORGUST_SERVER__EXTERNAL_URL: http://127.0.0.1:18491
FORGUST_SERVER__SSH_LISTEN: ""
# no release check: what the pages show must not depend on GitHub's latest release
FORGUST_SERVER__RELEASE_CHECK_URL: ""
FORGUST_SERVER__DATA_DIR: /tmp/forgust-e2e
FORGUST_SERVER__STATIC_DIR: web/dist
steps:
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ volumes: { forgust-data: {}, db-data: {} }
- Overview with activity and CI statistics
- Users: block, reset two-factor, impersonate (audited)
- Storage by kind and by project, quotas per group and project
- Background jobs, health checks, backups from the UI, an instance banner
- Background jobs, health checks, backups from the UI, an instance banner, a notice to administrators when a new release is out (can be turned off)

### 🔌 Integrations
- Import from GitLab and GitHub: repositories, merge requests, issues, labels,
Expand Down
2 changes: 1 addition & 1 deletion bench/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ cleanup() {
trap cleanup EXIT

export FORGUST_DATABASE__URL="$BASE/$DB" FORGUST_SERVER__DATA_DIR="$WORK/data" FORGUST_SERVER__LISTEN="127.0.0.1:$PORT" \
FORGUST_SERVER__EXTERNAL_URL="http://127.0.0.1:$PORT" FORGUST_SERVER__SSH_LISTEN= FORGUST_TELEMETRY__LOG_FILTER=warn
FORGUST_SERVER__EXTERNAL_URL="http://127.0.0.1:$PORT" FORGUST_SERVER__SSH_LISTEN= FORGUST_SERVER__RELEASE_CHECK_URL= FORGUST_TELEMETRY__LOG_FILTER=warn
$BIN/forgust admin create-user --username bench --email bench@example.com --password benchpassword --admin
$BIN/forgust serve > "$WORK/server.log" 2>&1 &
SERVER=$!
Expand Down
6 changes: 6 additions & 0 deletions crates/forgust-db/migrations/20261114193156_update_check.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Administrators hear of new Forgust releases (the latest GitHub release),
-- unless they turn the check off.
ALTER TABLE instance_settings
ADD COLUMN update_check_enabled boolean NOT NULL DEFAULT true,
ADD COLUMN latest_version text,
ADD COLUMN latest_release_url text;
42 changes: 35 additions & 7 deletions crates/forgust-db/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,39 @@ pub struct InstanceSettings {
pub allow_personal_projects: bool,
pub default_branch_name: String,
/// The MCP server answers.
#[serde(default = "on")]
pub mcp_enabled: bool,
/// Ask GitHub for new Forgust releases, to tell administrators.
pub update_check_enabled: bool,
}

fn on() -> bool {
true
pub async fn get(e: impl PgExecutor<'_>) -> Result<InstanceSettings> {
sqlx::query_as!(
InstanceSettings,
"SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled, update_check_enabled FROM instance_settings"
)
.fetch_one(e)
.await
}

pub async fn get(e: impl PgExecutor<'_>) -> Result<InstanceSettings> {
/// The settings, locked until the transaction ends: saves made of them (a
/// switch left out keeps its value) then go one at a time.
pub async fn get_for_update(e: impl PgExecutor<'_>) -> Result<InstanceSettings> {
sqlx::query_as!(
InstanceSettings,
"SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled FROM instance_settings"
"SELECT signup_enabled, allow_personal_projects, default_branch_name, mcp_enabled, update_check_enabled FROM instance_settings FOR UPDATE"
)
.fetch_one(e)
.await
}

pub async fn update(e: impl PgExecutor<'_>, s: &InstanceSettings) -> Result<()> {
sqlx::query!(
"UPDATE instance_settings SET signup_enabled = $1, allow_personal_projects = $2, default_branch_name = $3, mcp_enabled = $4, updated_at = now()",
"UPDATE instance_settings SET signup_enabled = $1, allow_personal_projects = $2, default_branch_name = $3, mcp_enabled = $4, update_check_enabled = $5, updated_at = now()",
s.signup_enabled,
s.allow_personal_projects,
s.default_branch_name,
s.mcp_enabled
s.mcp_enabled,
s.update_check_enabled
)
.execute(e)
.await?;
Expand All @@ -43,3 +52,22 @@ pub async fn update(e: impl PgExecutor<'_>, s: &InstanceSettings) -> Result<()>
pub async fn mcp_enabled(e: impl PgExecutor<'_>) -> Result<bool> {
sqlx::query_scalar!("SELECT mcp_enabled FROM instance_settings").fetch_one(e).await
}

/// The latest Forgust release found, and its page.
pub struct LatestRelease {
pub version: String,
pub url: Option<String>,
}

/// The latest release found, while the release check is on.
pub async fn latest_release(e: impl PgExecutor<'_>) -> Result<Option<LatestRelease>> {
let row = sqlx::query!("SELECT latest_version, latest_release_url FROM instance_settings WHERE update_check_enabled")
.fetch_optional(e)
.await?;
Ok(row.and_then(|r| Some(LatestRelease { version: r.latest_version?, url: r.latest_release_url })))
}

pub async fn set_latest_release(e: impl PgExecutor<'_>, version: &str, url: Option<&str>) -> Result<()> {
sqlx::query!("UPDATE instance_settings SET latest_version = $1, latest_release_url = $2", version, url).execute(e).await?;
Ok(())
}
1 change: 1 addition & 0 deletions crates/forgust-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ regex = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
percent-encoding = "2"
url = "2"
semver = "1"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "stream"] }
hmac = "0.13"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls", "ring", "webpki-roots"] }
Expand Down
58 changes: 54 additions & 4 deletions crates/forgust-server/src/api/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ const RUNNABLE: &[(&str, &str)] = &[
("maintenance_sweep", "Repository maintenance (full repack of repositories pushed to) and repository sizes"),
("cleanup", "Expired sessions and tokens, idle git processes"),
("measure_repositories", "Repository sizes for Storage"),
("update_check", "Ask GitHub for the latest Forgust release (unless turned off in Settings)"),
];

#[derive(Serialize, ToSchema)]
Expand Down Expand Up @@ -830,17 +831,66 @@ pub async fn settings(State(state): State<AppState>, Admin(..): Admin) -> ApiRes
Ok(Json(forgust_db::settings::get(&state.db).await?))
}

/// The instance settings to save. A switch left out keeps its value: a client
/// that predates it (or a script) does not turn it back on.
#[derive(Deserialize, ToSchema)]
pub struct UpdateInstanceSettings {
pub signup_enabled: bool,
pub allow_personal_projects: bool,
pub default_branch_name: String,
pub mcp_enabled: Option<bool>,
pub update_check_enabled: Option<bool>,
}

pub async fn update_settings(
State(state): State<AppState>,
Admin(_, p): Admin,
Json(req): Json<InstanceSettings>,
Json(req): Json<UpdateInstanceSettings>,
) -> ApiResult<Json<InstanceSettings>> {
if !forgust_storage::validate_rev(&req.default_branch_name) {
return Err(ApiError::bad("invalid default branch name"));
}
forgust_db::settings::update(&state.db, &req).await?;
services::audit(&state, &p, "instance_settings_updated", None, serde_json::to_value(&req).unwrap_or_default(), None).await;
Ok(Json(forgust_db::settings::get(&state.db).await?))
// one save at a time: a switch left out keeps what the last save wrote,
// not what this one read before another save landed
let mut tx = state.db.begin().await?;
let before = forgust_db::settings::get_for_update(&mut *tx).await?;
let after = InstanceSettings {
signup_enabled: req.signup_enabled,
allow_personal_projects: req.allow_personal_projects,
default_branch_name: req.default_branch_name,
mcp_enabled: req.mcp_enabled.unwrap_or(before.mcp_enabled),
update_check_enabled: req.update_check_enabled.unwrap_or(before.update_check_enabled),
};
forgust_db::settings::update(&mut *tx, &after).await?;
tx.commit().await?;
services::audit(&state, &p, "instance_settings_updated", None, serde_json::to_value(&after).unwrap_or_default(), None).await;
// turned (back) on: hear of the latest release now, not at the next 6-hourly check
if after.update_check_enabled
&& !before.update_check_enabled
&& let Err(e) = services::updates::queue_check(&state).await
{
tracing::warn!(error = %e, "could not queue the release check");
}
Ok(Json(after))
}

#[derive(Serialize, ToSchema)]
pub struct UpdateStatus {
pub current: String,
/// The latest release on GitHub, when it is newer than this one and the check is on.
pub available: Option<String>,
/// Its release notes, when GitHub gave them.
pub url: Option<String>,
}

/// Whether a newer Forgust release is out.
pub async fn update_status(State(state): State<AppState>, Admin(..): Admin) -> ApiResult<Json<UpdateStatus>> {
let current = env!("CARGO_PKG_VERSION");
let (available, url) = match services::updates::latest(&state).await? {
Some(l) if services::updates::newer(current, &l.version) => (Some(l.version), l.url),
_ => (None, None),
};
Ok(Json(UpdateStatus { current: current.into(), available, url }))
}

#[derive(Deserialize)]
Expand Down
1 change: 1 addition & 0 deletions crates/forgust-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ pub fn router() -> Router<AppState> {
.route("/admin/jobs/{job_id}/retry", post(admin::retry_job))
.route("/admin/jobs/{job_id}", delete(admin::drop_job))
.route("/admin/tasks", get(admin::runnable))
.route("/admin/update", get(admin::update_status))
.route("/admin/tasks/{task}", post(admin::run_task))
.route("/admin/health/email", post(admin::test_email))
.route("/admin/banner", get(admin::banner_settings).put(admin::set_banner))
Expand Down
Loading
Loading