forks/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somperf: prioritize push-triggered builds over background scans2688dec

darwin-flake
7.4 KiB222 linesraw
1//! `sorceryd`: serves generated sites and git data from behind nginx.
2
3use std::fs::{self, Permissions};
4use std::os::unix::fs::{FileTypeExt, PermissionsExt};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::{Context, Result, bail};
10use arc_swap::ArcSwap;
11use axum::Router;
12use axum::http::{HeaderValue, StatusCode};
13use axum::response::{IntoResponse, Response};
14use axum::routing::{any, get, post};
15use tokio::net::UnixListener;
16use tokio::sync::{Mutex, Notify, OwnedSemaphorePermit, RwLock, Semaphore, watch};
17
18use crate::catalog::Repository;
19use crate::highlight::Cache as GrammarCache;
20use cache::{BuildQueue, RepoEntry, Snapshot};
21
22mod cache;
23mod control;
24mod git;
25mod objects;
26mod site;
27
28#[derive(Clone)]
29pub struct Config {
30    pub repositories: PathBuf,
31    pub cache: PathBuf,
32    pub socket: PathBuf,
33    pub instance_name: String,
34    pub internal_prefix: String,
35    /// Internal location rooted at the *repositories* dir, for `.git` serving.
36    pub git_internal_prefix: String,
37    pub check_interval: Duration,
38    pub max_git_processes: usize,
39    /// Local socket accepting refresh requests; see `control`.
40    pub control_socket: Option<PathBuf>,
41    /// Public base URL (e.g. `https://git.example.org`); enables the clone
42    /// command on repository pages.
43    pub clone_url_base: Option<String>,
44}
45
46impl Config {
47    fn repo_cache(&self, repository: &Repository) -> PathBuf {
48        self.cache.join(&repository.user).join(&repository.name)
49    }
50
51    fn site_url(&self, repository: &Repository) -> Option<String> {
52        let base = self.clone_url_base.as_ref()?.trim_end_matches('/');
53        Some(format!("{base}/{}/{}", repository.user, repository.name))
54    }
55}
56
57type AppState = Arc<Inner>;
58
59struct Inner {
60    config: Config,
61    /// Immutable view of the repository catalog, replaced wholesale by the
62    /// background scanner. Request handlers only ever read it.
63    snapshot: ArcSwap<Snapshot>,
64    /// Builds hold this shared; garbage collection needs it exclusively.
65    cache_lock: RwLock<()>,
66    background: watch::Sender<Vec<RepoEntry>>,
67    build_queue: BuildQueue,
68    rescan_requested: Notify,
69    rescan_gate: Mutex<()>,
70    git_processes: Arc<Semaphore>,
71    highlights: Arc<GrammarCache>,
72}
73
74type WebError = (StatusCode, String);
75type WebResult<T> = Result<T, WebError>;
76
77pub async fn run(config: Config) -> Result<()> {
78    if config.instance_name.trim().is_empty() {
79        bail!("--instance-name must not be empty");
80    }
81    if !config.internal_prefix.starts_with('/') {
82        bail!("--internal-prefix must start with /");
83    }
84    if config.max_git_processes == 0 {
85        bail!("--max-git-processes must be greater than zero");
86    }
87    fs::create_dir_all(&config.cache)?;
88    site::stage_assets(&config.cache)?;
89    let listener = bind_unix(&config.socket)?;
90    let control = config
91        .control_socket
92        .as_deref()
93        .map(bind_unix)
94        .transpose()?;
95    if let Some(path) = &config.control_socket {
96        fs::set_permissions(path, Permissions::from_mode(0o600))?;
97    }
98
99    let highlights = Arc::new(GrammarCache::new(config.cache.join(".arborium")));
100    let state = Inner::new(config, highlights);
101    tokio::spawn(cache::background_worker(
102        state.clone(),
103        state.background.subscribe(),
104    ));
105    state.rescan().await.context("initial repository scan")?;
106    tokio::spawn(cache::scan_periodically(state.clone()));
107    if let Some(control) = control {
108        tokio::spawn(control::serve(state.clone(), control));
109    }
110
111    let app = Router::new()
112        .route("/", get(site::index))
113        .route("/css/style.css", get(site::stylesheet))
114        .route("/js/{*path}", get(site::js_asset))
115        .route("/fonts/{*path}", get(site::font_asset))
116        .route("/{user}", get(site::user_redirect))
117        .route("/{user}/", get(site::user_root))
118        .route("/{user}/{repo}", get(site::add_trailing_slash))
119        .route("/{user}/{repo}/", get(site::repo_root))
120        .route("/{user}/{repo}/.git/{*path}", get(git::git_dir))
121        .route("/{user}/{repo}/info/refs", get(git::info_refs))
122        .route("/{user}/{repo}/git-upload-pack", post(git::upload_pack))
123        .route("/{user}/{repo}/obj", any(objects::query))
124        .route("/{user}/{repo}/raw/{oid}/{*path}", get(git::raw_blob))
125        .route("/{user}/{repo}/{*path}", get(site::repo_path))
126        .with_state(state.clone());
127
128    let result = axum::serve(listener, app)
129        .with_graceful_shutdown(shutdown_signal())
130        .await;
131    let sockets = [
132        Some(&state.config.socket),
133        state.config.control_socket.as_ref(),
134    ];
135    for socket in sockets.into_iter().flatten() {
136        if socket.exists() {
137            fs::remove_file(socket)?;
138        }
139    }
140    result?;
141    Ok(())
142}
143
144/// Bind `path`, replacing a stale socket left behind by an earlier run.
145fn bind_unix(path: &Path) -> Result<UnixListener> {
146    if let Some(parent) = path.parent() {
147        fs::create_dir_all(parent)?;
148    }
149    if let Ok(metadata) = fs::symlink_metadata(path) {
150        if !metadata.file_type().is_socket() {
151            bail!("refusing to replace non-socket {}", path.display());
152        }
153        fs::remove_file(path)?;
154    }
155    UnixListener::bind(path).with_context(|| format!("binding {}", path.display()))
156}
157
158async fn shutdown_signal() {
159    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
160        .expect("installing SIGTERM handler");
161    tokio::select! {
162        _ = tokio::signal::ctrl_c() => {}
163        _ = terminate.recv() => {}
164    }
165}
166
167impl Inner {
168    fn new(config: Config, highlights: Arc<GrammarCache>) -> AppState {
169        Arc::new(Inner {
170            snapshot: ArcSwap::from_pointee(Snapshot::default()),
171            cache_lock: RwLock::new(()),
172            background: watch::channel(Vec::new()).0,
173            build_queue: BuildQueue::new(),
174            rescan_requested: Notify::new(),
175            rescan_gate: Mutex::new(()),
176            git_processes: Arc::new(Semaphore::new(config.max_git_processes)),
177            highlights,
178            config,
179        })
180    }
181
182    /// Look up a repository in the current snapshot, tolerating a `.git`
183    /// suffix on the request (so `git clone .../user/repo.git` works too).
184    fn resolve(&self, user: &str, name: &str) -> WebResult<RepoEntry> {
185        let name = name.strip_suffix(".git").unwrap_or(name);
186        self.snapshot
187            .load()
188            .repos
189            .get(&format!("{user}/{name}"))
190            .cloned()
191            .ok_or_else(|| (StatusCode::NOT_FOUND, "repository not found".into()))
192    }
193
194    fn git_permit(&self) -> WebResult<OwnedSemaphorePermit> {
195        self.git_processes.clone().try_acquire_owned().map_err(|_| {
196            (
197                StatusCode::SERVICE_UNAVAILABLE,
198                "git service is busy".into(),
199            )
200        })
201    }
202}
203
204/// Hand the (already percent-encoded) path beneath `prefix` to nginx.
205fn x_accel(prefix: &str, encoded_path: &str) -> WebResult<Response> {
206    let location = format!("{}/{encoded_path}", prefix.trim_end_matches('/'));
207    let location = HeaderValue::from_str(&location).map_err(|_| {
208        (
209            StatusCode::INTERNAL_SERVER_ERROR,
210            "invalid internal path".into(),
211        )
212    })?;
213    Ok([("x-accel-redirect", location)].into_response())
214}
215
216fn internal(error: impl std::fmt::Display) -> WebError {
217    eprintln!("sorceryd: {error}");
218    (
219        StatusCode::INTERNAL_SERVER_ERROR,
220        "internal server error".into(),
221    )
222}