feat(job-registry): remplace /api/admin/internal/trigger-*

remplace /api/admin/internal/trigger-* to /api/admin/jobs/{...}/trigger
remove OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS
This commit is contained in:
Edouard Vanbelle
2026-07-27 23:40:40 +02:00
parent dfedde54a4
commit f66f7fa31f
24 changed files with 155 additions and 464 deletions
@@ -65,8 +65,8 @@ impl StorageUsageService {
/// `GET /api/drives` therefore lags by up to the cache TTL (30 s),
/// which matches the sibling caches' accepted UX phantom for
/// drive-name staleness. Tests / operators that need immediate
/// freshness call `POST /api/admin/internal/trigger-sweep`, which
/// runs `update_all_drives_storage_usage` → this method.
/// freshness call `POST /api/admin/jobs/storage_reconcile/trigger`,
/// which runs `update_all_drives_storage_usage` → this method.
///
/// Security posture unaffected: `check_drive_quota` reads
/// directly from SQL, bypassing the cache entirely, so quota
-23
View File
@@ -1078,15 +1078,6 @@ pub struct FeaturesConfig {
/// trash/search). OFF by default — opt-in per deployment.
/// Env: `OXICLOUD_ENABLE_EXTERNAL_MOUNTS`.
pub enable_external_mounts: bool,
/// Expose `/api/admin/internal/*` test-only endpoints that trigger
/// background sweeps on demand (storage-usage reconciliation, blob
/// GC). Intended for Hurl / integration tests that need to wait
/// for these maintenance jobs deterministically rather than
/// polling the cached value. Off by default — these endpoints
/// short-circuit the operator-visible cadence, so production
/// deployments don't want them reachable. Env:
/// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`.
pub enable_admin_internal_endpoints: bool,
/// Native WebDAV path segment that lists the caller's drives.
///
/// * Default `"@drive"` — bare `/webdav/` addresses the caller's
@@ -1163,10 +1154,6 @@ impl Default for FeaturesConfig {
expose_system_users: true, // Expose OxiCloud users as address book by default
enable_video_thumbnails: true, // Video thumbs via ffmpeg (if detected)
enable_external_mounts: false, // External mounts — opt-in, off by default
// Test-only sweep triggers — strictly opt-in. Production
// deployments do NOT need this; the periodic ticker handles
// reconciliation transparently.
enable_admin_internal_endpoints: false,
// Back-compat with pre-multi-drive clients — bare `/webdav/`
// maps to the caller's default drive; drive listing is
// reachable at `/webdav/@drive/`.
@@ -1867,16 +1854,6 @@ impl AppConfig {
config.features.enable_video_thumbnails = val;
}
// `/api/admin/internal/*` test-only triggers. Disabled by
// default; production deployments never need this. The Hurl
// suite flips it on via `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`.
if let Ok(enable_internal) =
env::var("OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_internal
{
config.features.enable_admin_internal_endpoints = val;
}
// Grant-cleanup daemon. Purges rows from `storage.role_grants`
// whose `expires_at` is more than `grace_days` in the past.
// See `GrantCleanupConfig` for defaults + rationale.
+6 -5
View File
@@ -2309,11 +2309,12 @@ pub struct AppState {
pub places_service: Option<Arc<PlacesService>>,
pub people_service: Option<Arc<PeopleService>>,
pub storage_usage_service: Option<Arc<StorageUsageService>>,
/// Handle to the background daemon that purges expired
/// `storage.role_grants` rows. `None` when the daemon is disabled
/// via `OXICLOUD_GRANT_CLEANUP_ENABLED=false`. The admin
/// `POST /api/admin/internal/trigger-grant-cleanup` handler uses
/// this to invoke the purge on demand (test-only).
/// Handle to the service that purges expired `storage.role_grants`
/// rows. Registered with the periodic-job scheduler on the
/// configured cadence; `None` when disabled via
/// `OXICLOUD_GRANT_CLEANUP_ENABLED=false`. Exposed on `AppState`
/// so the admin trigger endpoint can invoke `purge(Some(0))` for
/// the `?force=true` grace-override path.
pub grant_cleanup_service: Option<
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
>,
+5 -8
View File
@@ -117,11 +117,7 @@ async fn run(registry: Arc<JobRegistry>) {
/// `args` is passed through to `JobHandler::run`. The supervisor's
/// periodic ticks pass `JobRunArgs::default()`; the admin trigger
/// endpoint forwards parsed query params such as `?force=true`.
pub(super) async fn dispatch(
name: &str,
entry: Arc<JobEntry>,
args: &JobRunArgs,
) -> JobOutcome {
pub(super) async fn dispatch(name: &str, entry: Arc<JobEntry>, args: &JobRunArgs) -> JobOutcome {
// Try to acquire the single-permit gate. `try_acquire` is
// non-blocking — if held, we know the previous run is still
// executing and skip this tick.
@@ -372,9 +368,10 @@ mod tests {
// Kick off dispatch 1 in the background — it holds the permit
// for ~200 ms.
let entry_bg = entry.clone();
let bg = tokio::spawn(async move {
dispatch("overrun", entry_bg, &JobRunArgs::default()).await
});
let bg =
tokio::spawn(
async move { dispatch("overrun", entry_bg, &JobRunArgs::default()).await },
);
// Give dispatch 1 time to grab the permit.
tokio::time::sleep(Duration::from_millis(50)).await;
+2 -10
View File
@@ -216,11 +216,7 @@ impl JobRegistry {
/// `args` is forwarded to `JobHandler::run`. Admin trigger routes
/// use `JobRunArgs { force: query.force }`; programmatic callers
/// that just want a plain run pass `JobRunArgs::default()`.
pub async fn trigger(
self: &Arc<Self>,
name: &str,
args: &JobRunArgs,
) -> Option<JobOutcome> {
pub async fn trigger(self: &Arc<Self>, name: &str, args: &JobRunArgs) -> Option<JobOutcome> {
let entry = self.get(name).await?;
Some(super::engine::dispatch(name, entry, args).await)
}
@@ -367,10 +363,6 @@ mod tests {
#[tokio::test]
async fn trigger_returns_none_for_unknown_job() {
let reg = Arc::new(JobRegistry::new());
assert!(
reg.trigger("nope", &JobRunArgs::default())
.await
.is_none()
);
assert!(reg.trigger("nope", &JobRunArgs::default()).await.is_none());
}
}
+6 -6
View File
@@ -2471,15 +2471,15 @@ impl DedupService {
.await
}
/// Test-only variant that bypasses the orphan grace window — used by
/// `POST /api/admin/internal/trigger-gc?force=true` so the
/// Test-only variant that bypasses the orphan grace window — used
/// by `POST /api/admin/jobs/dedup_gc/trigger?force=true` (via the
/// `JobRunArgs.force` dispatch in `JobHandler::run`) so the
/// integration suite can reap just-orphaned blobs synchronously
/// (waiting out the production 1 h grace inside a test run is a
/// non-starter). Drops the same rows the regular sweep would, just
/// without the time floor. Unsafe under concurrent uploads because
/// it reopens the TOCTOU window the grace closes — only the
/// admin-internal route, itself gated by
/// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`, may reach here.
/// admin-triggered `?force=true` path reaches here.
pub async fn garbage_collect_force(&self) -> Result<(u64, u64), DomainError> {
self.garbage_collect_with_grace(0).await
}
@@ -3150,8 +3150,8 @@ impl crate::infrastructure::scheduler::JobHandler for DedupService {
/// cleanup already reaped everything.
///
/// `args.force = true` skips the orphan grace window
/// (`garbage_collect_force` — grace_secs = 0), matching the legacy
/// `POST /admin/internal/trigger-gc?force=true` semantics. Unsafe
/// (`garbage_collect_force` — grace_secs = 0). Same semantic as
/// `POST /api/admin/jobs/dedup_gc/trigger?force=true`. Unsafe
/// under concurrent uploads: only reachable through the admin
/// endpoint and only intentionally used by tests + operator
/// diagnostic sessions.
@@ -118,9 +118,9 @@ impl JobHandler for GrantCleanupService {
/// listings can see it without a second lookup.
///
/// `args.force = true` collapses the grace window to zero for
/// this run only — matches the legacy
/// `POST /admin/internal/trigger-grant-cleanup?force=true` shape.
/// The configured `self.grace_days` is not mutated.
/// this run only — same semantic as
/// `POST /api/admin/jobs/grant_cleanup/trigger?force=true`. The
/// configured `self.grace_days` is not mutated.
async fn run(&self, args: &JobRunArgs) -> JobOutcome {
let grace_override = if args.force { Some(0) } else { None };
let effective_grace = grace_override.unwrap_or(self.grace_days);
+4 -257
View File
@@ -24,7 +24,6 @@ use crate::application::dtos::settings_dto::{
use crate::application::dtos::user_dto::{AdminUserSummaryDto, UserDto};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::{Resource, Subject};
@@ -144,21 +143,10 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
// when `OXICLOUD_SMTP_MOCK` is off, so production deployments
// can route the path freely without leaking inboxes.
.route("/smtp/test/captured", get(get_captured_email))
// Test-only sweep triggers. Routes are always registered; the
// handlers themselves short-circuit to 404 when
// `features.enable_admin_internal_endpoints` is off — matches
// the `/smtp/test/captured` convention so production
// deployments don't need a different route table.
.route("/internal/trigger-sweep", post(internal_trigger_sweep))
.route("/internal/trigger-gc", post(internal_trigger_gc))
.route(
"/internal/trigger-grant-cleanup",
post(internal_trigger_grant_cleanup),
)
// JobRegistry admin surface — production, always-on,
// audit-logged. See `docs/plan/job-registry.md` §Cross-cutting.
// The `/internal/trigger-*` shims above will be retired in a
// follow-up PR (deprecated forwards to these endpoints).
// Retired the `/internal/trigger-sweep|gc|grant-cleanup` shims
// that used to sit here (Stage 2 of the job-registry rollout).
.route("/jobs", get(list_jobs))
.route("/jobs/{name}/trigger", post(trigger_job))
// Drives — admin-wide view (distinct from `/api/drives` which
@@ -2065,246 +2053,6 @@ pub async fn delete_drive_admin(
Ok(StatusCode::NO_CONTENT)
}
// ════════════════════════════════════════════════════════════════════════════
// Test-only sweep triggers (`/api/admin/internal/*`)
//
// Wraps the periodic background jobs (storage-usage reconciliation,
// blob garbage collection) behind admin-gated synchronous endpoints
// so Hurl / integration tests can wait for them deterministically
// rather than polling the cached value. Disabled at the handler edge
// when `features.enable_admin_internal_endpoints == false` — match
// the `/smtp/test/captured` convention so production deployments
// don't need a different route table.
// ════════════════════════════════════════════════════════════════════════════
/// Refusal when the test-only endpoints are disabled. Returns 404
/// rather than 403 to avoid leaking the route's existence (and the
/// corresponding config flag) to an unauthenticated probe — the
/// legitimate test runner sets the env explicitly.
fn internal_endpoints_disabled() -> axum::response::Response {
use axum::response::IntoResponse;
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "endpoint not available" })),
)
.into_response()
}
/// `POST /api/admin/internal/trigger-sweep` — run the storage-usage
/// reconciliation sweep synchronously.
///
/// Test-only. Recomputes `users.storage_used_bytes` and
/// `drives.used_bytes` from `SUM(size) WHERE NOT is_trashed`, in the
/// same set-based UPDATEs the periodic ticker runs. Used by Hurl
/// suites that need to assert post-delete quota convergence without
/// waiting out the sweep interval (default 600 s).
#[utoipa::path(
post,
path = "/api/admin/internal/trigger-sweep",
responses(
(status = 200, description = "Sweep ran"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn internal_trigger_sweep(
State(state): State<Arc<AppState>>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
let svc = match state.storage_usage_service.as_ref() {
Some(s) => s,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "storage_usage_service not available",
})),
)
.into_response();
}
};
// Order matches the periodic ticker (`start_reconciliation_job`):
// drive sweep first because the user sweep reads `drives.used_bytes`
// (sum-of-personal-drives — `docs/plan/drive.md` §7). Running them
// in the other order makes the user counter freeze on the previous
// tick's drive numbers — invisible in steady state but breaks any
// Hurl that trashes + sweeps within one call.
if let Err(e) = svc.update_all_drives_storage_usage().await {
return AppError::internal_error(format!("drive sweep failed: {e}")).into_response();
}
if let Err(e) = svc.update_all_users_storage_usage().await {
return AppError::internal_error(format!("user sweep failed: {e}")).into_response();
}
(
StatusCode::OK,
Json(serde_json::json!({ "ok": true, "ran": ["drives", "users"] })),
)
.into_response()
}
/// Query parameters for `POST /api/admin/internal/trigger-gc`.
///
/// `force=true` bypasses the orphan-grace window so the sweep reaps
/// just-orphaned blobs in the same call. Without this, a blob orphaned
/// less than `GC_ORPHAN_GRACE_SECS` (1 h) ago survives the sweep — the
/// grace exists so a concurrent uploader pinning a just-orphaned chunk
/// can't race the row-delete → file-unlink gap. Integration tests
/// don't have concurrent uploaders, so the test runner sets
/// `force=true` to make the sweep deterministic within a test's
/// runtime.
#[derive(Debug, serde::Deserialize, Default)]
pub struct InternalTriggerGcQuery {
#[serde(default)]
pub force: bool,
}
/// `POST /api/admin/internal/trigger-gc` — run the blob garbage
/// collector synchronously.
///
/// Test-only. Drops `file_blobs` rows with `ref_count = 0` (subject
/// to the orphan-grace window) and their on-disk content. Same call
/// as the inline post-purge GC and the periodic blob-GC sweep — just
/// exposed under an admin route so Hurl can wait for it
/// deterministically. Add `?force=true` to bypass the grace window —
/// see [`InternalTriggerGcQuery`].
#[utoipa::path(
post,
path = "/api/admin/internal/trigger-gc",
params(("force" = Option<bool>, Query, description = "Bypass the orphan-grace window (test-only)")),
responses(
(status = 200, description = "GC ran"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn internal_trigger_gc(
State(state): State<Arc<AppState>>,
Query(query): Query<InternalTriggerGcQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
let result = if query.force {
state.core.dedup_service.garbage_collect_force().await
} else {
state.core.dedup_service.garbage_collect().await
};
match result {
Ok((blobs_deleted, bytes_freed)) => (
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"blobs_deleted": blobs_deleted,
"bytes_freed": bytes_freed,
"forced": query.force,
})),
)
.into_response(),
Err(e) => AppError::internal_error(format!("gc failed: {e}")).into_response(),
}
}
/// Query parameters for `POST /api/admin/internal/trigger-grant-cleanup`.
///
/// `force=true` sets the grace window to `0` for this call — deletes
/// every row whose `expires_at` is in the past, right now. Enables
/// Hurl regressions to plant a past-dated grant and immediately
/// observe it purged, without waiting the configured
/// `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` out.
///
/// Without `force`, the daemon's configured grace applies — the same
/// SQL the daily loop runs.
#[derive(Debug, serde::Deserialize, Default)]
pub struct InternalTriggerGrantCleanupQuery {
#[serde(default)]
pub force: bool,
}
/// `POST /api/admin/internal/trigger-grant-cleanup` — run the expired-
/// grant purge synchronously.
///
/// Test-only. Deletes rows from `storage.role_grants` whose
/// `expires_at` is more than `grace_days` in the past (or immediately,
/// with `?force=true`). Same SQL as the periodic `GrantCleanupService`
/// daemon — exposed under an admin route so Hurl can wait for it
/// deterministically.
///
/// Response fields:
/// `grants_deleted` — count of rows removed by this invocation
/// `grace_days` — the grace window that was applied (0 when
/// `?force=true`, otherwise the config value)
/// `forced` — echoes the query param
#[utoipa::path(
post,
path = "/api/admin/internal/trigger-grant-cleanup",
params(("force" = Option<bool>, Query, description = "Force grace = 0 for this run (test-only)")),
responses(
(status = 200, description = "Purge ran"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
(status = 503, description = "Grant-cleanup daemon disabled (OXICLOUD_GRANT_CLEANUP_ENABLED=false)"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn internal_trigger_grant_cleanup(
State(state): State<Arc<AppState>>,
Query(query): Query<InternalTriggerGrantCleanupQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
// Daemon may be disabled by config even when the internal-endpoint
// gate is on. Return 503 (rather than 404 or 500) so integration
// tests can distinguish "surface not exposed" from "surface
// exposed but backing service off".
let svc = match state.grant_cleanup_service.as_ref() {
Some(s) => s,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "grant_cleanup_service not available (disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false)",
})),
)
.into_response();
}
};
// `force=true` collapses the grace window to zero for this run
// only — the daemon's configured grace is untouched. Mirrors the
// `trigger-gc?force=true` shape.
let grace_override = if query.force { Some(0) } else { None };
let grants_deleted = match svc.purge(grace_override).await {
Ok(n) => n,
Err(e) => {
return AppError::internal_error(format!("grant cleanup failed: {e}")).into_response();
}
};
let grace_days = grace_override.unwrap_or_else(|| svc.grace_days());
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"grants_deleted": grants_deleted,
"grace_days": grace_days,
"forced": query.force,
})),
)
.into_response()
}
// ─────────────────────────────────────────────────────
// JobRegistry admin surface (`/api/admin/jobs/*`)
// ─────────────────────────────────────────────────────
@@ -2312,9 +2060,8 @@ pub async fn internal_trigger_grant_cleanup(
/// `GET /api/admin/jobs` — enumerate every registered job with its
/// interval, next-run/last-run timestamps, and last outcome.
///
/// Production endpoint (no `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`
/// gate). Read-only, so no audit line — the standard admin-middleware
/// auth check is enough.
/// Production endpoint, always on. Read-only, so no audit line —
/// the standard admin-middleware auth check is enough.
#[utoipa::path(
get,
path = "/api/admin/jobs",
+6 -7
View File
@@ -228,13 +228,12 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::admin_handler::complete_migration,
handlers::admin_handler::verify_migration,
handlers::admin_handler::generate_encryption_key,
// Admin internal-trigger handlers — gated by
// OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS (Off by default in
// prod; on for the Hurl suite). Documented in OpenAPI so
// integrators writing test harnesses can discover the surface.
handlers::admin_handler::internal_trigger_sweep,
handlers::admin_handler::internal_trigger_gc,
handlers::admin_handler::internal_trigger_grant_cleanup,
// JobRegistry admin surface — production, always-on,
// audit-logged. Retired the `/internal/trigger-*` handlers in
// favour of `/api/admin/jobs/{name}/trigger` uniform surface
// (docs/plan/job-registry.md §Cross-cutting).
handlers::admin_handler::list_jobs,
handlers::admin_handler::trigger_job,
// Grant / ReBAC handlers (free functions)
handlers::grant_handler::create_grant,
handlers::grant_handler::revoke_grant,