dev-updater: build an app on the machine, install it on the phone

A Rust backend that discovers Android projects under configured roots,
builds one on request, and serves the APK over pinned TLS on a WireGuard
interface; an Android client that lists what is buildable, watches a build,
and installs the result. Enrolment carries the token and the CA, so the
phone trusts exactly the machine that issued it and nothing else.

`AGENTS.md` is the working guide and `README.md` the configuration
reference. The shared tunnel-and-TLS code lives in `vendor/wg-app-link`,
which ai-app uses too.

History before this point was squashed away, and a stale `config.json` went
with it: nothing had read that file since the config moved to RON outside
the checkout, and what it still held was one machine's absolute paths and
the names of projects on it.
This commit is contained in:
iris committed 2026-08-31 20:31:08 -04:00
commit b0e83059a3
82 files changed
+20372

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
//! Reading an APK's own identity -- package name and display label -- out of
//! the built file, so adding an app needs nothing but a path.
//!
//! The package name is not cosmetic: it is what the updater app looks up
//! with `PackageManager` to tell "already installed this build" apart from
//! "update available", so an app whose package can't be read can't be
//! served usefully and is rejected at add time rather than showing up as a
//! permanently-out-of-date card.
//!
//! This shells out to `aapt2`, which costs a process spawn (tens of
//! milliseconds), so it never runs on the manifest path. It runs when an
//! app is added, and off the request path after a download, which is the
//! one moment this server can notice that a local rebuild changed what the
//! APK installs over. The answer is cached on the component in the config
//! (`Component::Apk`'s `package`).
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
use crate::sdk;
pub struct ApkInfo {
pub package: String,
/// The APK's own `android:label`, when it has one -- what the user sees
/// on the device, so a better card title than the directory name.
pub label: Option<String>,
}
pub fn read(apk: &Path) -> Result<ApkInfo> {
let output = Command::new(sdk::aapt2_path()?)
.arg("dump")
.arg("badging")
.arg(apk)
.output()
.context("failed to spawn aapt2")?;
if !output.status.success() {
bail!(
"aapt2 couldn't read {} as an APK: {}",
apk.display(),
String::from_utf8_lossy(&output.stderr).trim(),
);
}
let badging = String::from_utf8_lossy(&output.stdout);
let package = badging
.lines()
.find_map(|line| line.strip_prefix("package:"))
.and_then(|rest| quoted_value(rest, "name="))
.with_context(|| format!("no package name in aapt2's output for {}", apk.display()))?;
// `application-label:'X'` is the default-locale label; `application:`
// carries the same thing plus the icon. Either is fine, and which one
// appears depends on the aapt2 version, so accept whichever is present.
let label = badging
.lines()
.find_map(|line| line.strip_prefix("application-label:"))
.and_then(|rest| {
rest.trim()
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
})
.map(str::to_owned)
.or_else(|| {
badging
.lines()
.find_map(|line| line.strip_prefix("application:"))
.and_then(|rest| quoted_value(rest, "label="))
})
.filter(|label| !label.is_empty());
Ok(ApkInfo { package, label })
}
/// Pulls `key='value'` out of one of aapt2's space-separated badging lines.
fn quoted_value(line: &str, key: &str) -> Option<String> {
let rest = line.split_once(key)?.1;
let rest = rest.strip_prefix('\'')?;
let (value, _) = rest.split_once('\'')?;
Some(value.to_owned())
}
+200
View File
@@ -0,0 +1,200 @@
//! Bearer-token auth for the TLS listener's whole surface.
//!
//! Everything on that listener either is, or decides, what gets handed to
//! `REQUEST_INSTALL_PACKAGES` next, and the management routes can repoint
//! the scanner, add apps by path, and trigger a configured build command.
//! Pinning authenticates this server to the phone but never the phone to
//! this server, so the token supplies the other direction; binding the
//! WireGuard interface narrows who can try at all, and this narrows it to
//! who was enrolled.
//!
//! The middleware is applied once around the whole router (including the
//! fallback) in `main.rs`, never per-route, so a new route can't forget
//! it. The bootstrap listener is deliberately *not* wrapped: it exists for
//! a browser that has nothing to authenticate with yet, and serves only
//! this app's own APK.
//!
//! Nothing in this module -- and nothing anywhere else -- may log the
//! Authorization header or the token; `token_is_never_logged` below holds a
//! tripwire against a logging change silently starting to.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use crate::registry::AppState;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
/// Applied to every rejection. Not against brute force -- infeasible at 256
/// bits -- but so a scanner probing the port shows up as a slow, loggable
/// drip rather than a fast one.
const REJECT_DELAY: Duration = Duration::from_millis(300);
/// The scheme the app registers for enrollment URIs.
///
/// The one product-specific thing about enrollment, which is why the
/// shared implementation takes it as an argument rather than knowing it.
const ENROLL_SCHEME: &str = "devupdater";
/// Re-exported rather than wrapped: `main` and the routes both enroll, and
/// a wrapper here would only be a second name for the same function.
pub use wg_app_link::enroll::{generate_token, token_hash_hex};
/// Prints the one-time enrollment QR: a `devupdater://enroll` URI carrying
/// where to connect and the bearer token. The CA stays embedded in the APK,
/// so this carries no trust material -- photographing the terminal leaks
/// only the token, which is rotatable (`--rotate-token`).
pub fn print_enrollment(host: IpAddr, port: u16, token: &str) -> anyhow::Result<()> {
wg_app_link::enroll::print_enrollment(ENROLL_SCHEME, host, port, token)
}
pub async fn require_token(
State(state): State<Arc<AppState>>,
request: Request,
next: Next,
) -> Response {
let presented = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "));
if let Some(token) = presented {
let hashes: Vec<String> = state
.tokens()
.into_iter()
.map(|entry| entry.sha256)
.collect();
if wg_app_link::enroll::token_matches(token, &hashes) {
return next.run(request).await;
}
}
// Peer address only -- never the header value. Absent when there is no
// real socket (tests driving the router directly).
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.to_string())
.unwrap_or_else(|| "unknown peer".to_string());
tracing::warn!("rejected request from {peer}: missing or invalid bearer token");
tokio::time::sleep(REJECT_DELAY).await;
(StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use axum::Router;
use axum::body::Body;
use axum::routing::get;
use tower::ServiceExt;
use crate::config::TokenEntry;
fn state_with_token(dir: &std::path::Path, token: &str) -> Arc<AppState> {
let state =
Arc::new(AppState::new(dir.join("config.ron"), dir.join("app")).expect("state"));
state
.set_tokens(vec![TokenEntry {
name: "phone".to_string(),
sha256: token_hash_hex(token),
}])
.expect("set token");
state
}
fn guarded_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/probe", get(|| async { "ok" }))
.fallback(|| async { StatusCode::NOT_FOUND })
.layer(axum::middleware::from_fn_with_state(state, require_token))
}
fn request(path: &str, auth: Option<&str>) -> Request {
let mut builder = axum::http::Request::builder().uri(path);
if let Some(auth) = auth {
builder = builder.header(header::AUTHORIZATION, auth);
}
builder.body(Body::empty()).expect("request")
}
/// One test rather than separate gating and logging tests,
/// deliberately: tracing caches callsite interest process-wide, so a
/// test that hits the rejection path with no subscriber installed can
/// poison the interest cache for the one that captures logs. Keeping
/// every exercise of the middleware under the capturing subscriber
/// makes the log assertions deterministic.
#[tokio::test]
async fn gates_every_route_and_never_logs_the_token() {
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Capture {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
type Writer = Capture;
fn make_writer(&'a self) -> Capture {
self.clone()
}
}
let capture = Capture::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(capture.clone())
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let dir = tempfile::tempdir().expect("tempdir");
let token = generate_token();
let router = guarded_router(state_with_token(dir.path(), &token));
// No header, wrong token, wrong scheme: 401 everywhere, including
// paths that don't exist -- a scanner learns nothing.
for (path, auth) in [
("/probe", None),
("/probe", Some("Bearer wrong".to_string())),
("/probe", Some(format!("Basic {token}"))),
("/no-such-route", None),
] {
let response = router
.clone()
.oneshot(request(path, auth.as_deref()))
.await
.expect("response");
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{path} {auth:?}"
);
}
let ok = router
.clone()
.oneshot(request("/probe", Some(&format!("Bearer {token}"))))
.await
.expect("response");
assert_eq!(ok.status(), StatusCode::OK);
// The tripwire that keeps a future logging change (e.g. logging
// request headers) from silently leaking credentials.
let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned();
assert!(
!logged.contains(&token),
"the bearer token leaked into the logs: {logged}"
);
// The rejections themselves do get logged (that's the point).
assert!(logged.contains("missing or invalid bearer token"));
}
}
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
# Runs a build command and reports its progress to Dev Updater, for the
# build systems that cannot be asked for it directly.
#
# <this> gradle ./gradlew :androidApp:assembleDebug
#
# Dev Updater draws a real bar from `@@progress done/total` lines on a
# build's output, and ignores anything that is not exactly that shape. Most
# tools can be made to report their own counts -- cargo does, once
# `CARGO_TERM_PROGRESS_WHEN=always` is set, which the server sets for every
# build it runs, so a cargo build needs nothing and must not be wrapped in
# this. Gradle is the one that cannot, and this is why this file exists
# rather than the knowledge being copied into each project's build script.
#
# The server writes this out and points `$DEV_UPDATER_PROGRESS` at it, so a
# project's script uses it when it is being built by Dev Updater and runs
# the command plainly when somebody is building by hand:
#
# if [ -x "${DEV_UPDATER_PROGRESS:-}" ]; then
# "$DEV_UPDATER_PROGRESS" gradle ./gradlew :androidApp:assembleDebug
# else
# ./gradlew :androidApp:assembleDebug
# fi
#
# Exits with the build's own status, which is the thing that must not be
# lost: a wrapper that reports a failed build as a success is worse than no
# wrapper.
set -eu
FLAVOUR="${1:?usage: $0 <gradle> <command...>}"
shift
case "$FLAVOUR" in
gradle)
# Gradle cannot be asked for a count directly. An init script using
# taskGraph.afterTask is rejected outright by the configuration
# cache, and whenReady never fires on a cache hit. Its rich console
# does print a percentage, but only as a full-screen redraw --
# cursor movement, erases and IDLE lines -- which would make the
# output unreadable, and the output is the other half of what the
# card shows.
#
# --dry-run costs about a second, is cache-friendly, and prints one
# ":task SKIPPED" line per task the real build will run, which is
# exactly the total. The build then prints one "> Task :x" line per
# task as it goes, so counting those against it is the whole
# mechanism.
#
# Task count is not time -- compileDebugKotlin and dexBuilder are
# most of the wall clock -- so the bar moves unevenly. It is still
# counted work rather than a guess at how long last time took.
TASKS=$("$@" --dry-run --console=plain 2>/dev/null |
grep -c '^:[A-Za-z:]* SKIPPED' || true)
if [ "${TASKS:-0}" -le 0 ]; then
# No total, so no honest bar. The build still runs and still
# prints; the card shows a bar that only spins, which is what
# not knowing looks like.
exec "$@"
fi
echo "@@progress 0/$TASKS"
"$@" --console=plain 2>&1 | (
DONE=0
while IFS= read -r line; do
echo "$line"
case "$line" in
"> Task "*)
DONE=$((DONE + 1))
echo "@@progress $DONE/$TASKS"
;;
esac
done
)
# The pipeline's status is the subshell's, not the build's, so ask
# again rather than reporting a failed build as a success. It is up
# to date by now, so this costs a second.
exec "$@" --console=plain >/dev/null
;;
*)
echo "$0: unknown build system '$FLAVOUR'" >&2
echo "cargo reports its own progress and needs no wrapper." >&2
exit 2
;;
esac
File diff suppressed because it is too large. Load diff
+363
View File
@@ -0,0 +1,363 @@
//! Asking something slow in the background, so the request path never
//! waits for it.
//!
//! Two things here are asked about a project without being fast enough to
//! ask while answering a request: whether a checkout's remote has commits
//! it hasn't (a network round trip), and what a service is doing (a
//! process spawn). `/manifest` is fetched on every open, resume and
//! Refresh, so neither may happen inside it.
//!
//! The arrangement both need is the same one, and it was written twice
//! before this module existed. An answer lands *after* the response that
//! started the work, so a card shows "still finding out" until the next
//! look; a second request must not start a second worker for the same
//! thing; and a failure must not be able to masquerade as an answer --
//! which is the part that had already gone wrong once. A remote check that
//! failed left `new_commits` at `false`, and a card with no badge is how
//! this says "asked, and there is nothing new". The failure read as good
//! news. So an entry keeps the last answer *and* why the last attempt
//! produced none, and whatever displays it can say both.
//!
//! Being one mechanism matters more than the lines it saves: the counting
//! of what is still outstanding was once written against remote checks
//! alone, and service checks -- added later, in the same shape -- were
//! left out of it. The symptom was a component's buttons missing after a
//! restart, on whichever cards lost the race, with nothing looking broken.
use std::borrow::Borrow;
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::{Arc, Mutex};
/// What one attempt learned.
///
/// Both halves are optional, and the combinations are all real: an answer
/// with no error is success; an error with no answer is a failure that
/// found out nothing; and *both* is a partial answer worth keeping
/// alongside the reason it is not the whole one -- a service whose status
/// could not be read while its logs could.
pub struct Report<A> {
/// What is now known. `None` leaves whatever was known before, because
/// a failed attempt is a reason to keep showing the last answer rather
/// than to claim ignorance.
pub answer: Option<A>,
/// Why this attempt did not fully succeed. Cleared by an attempt that
/// did, so it never outlives the condition it describes.
pub error: Option<String>,
}
impl<A> Report<A> {
pub fn answered(answer: A) -> Self {
Self {
answer: Some(answer),
error: None,
}
}
pub fn failed(error: String) -> Self {
Self {
answer: None,
error: Some(error),
}
}
}
impl<A> From<Result<A, String>> for Report<A> {
fn from(result: Result<A, String>) -> Self {
match result {
Ok(answer) => Self::answered(answer),
Err(error) => Self::failed(error),
}
}
}
struct Entry<A> {
answer: Option<A>,
/// A worker is running for this key right now, so a second request
/// must not start another.
in_flight: bool,
error: Option<String>,
}
// Written out rather than derived: `#[derive(Default)]` would demand
// `A: Default`, and an answer that has never been given is `None` whether
// or not its type has a default.
impl<A> Default for Entry<A> {
fn default() -> Self {
Self {
answer: None,
in_flight: false,
error: None,
}
}
}
/// Answers being worked out in the background, by key.
///
/// Held behind an `Arc` by whatever owns it, because a worker outlives the
/// call that started it and has to be able to write its answer somewhere
/// that still exists.
pub struct Checks<K, A>(Mutex<HashMap<K, Entry<A>>>);
impl<K, A> Default for Checks<K, A> {
fn default() -> Self {
Self(Mutex::new(HashMap::new()))
}
}
impl<K: Eq + Hash + Clone + Send + 'static, A: Clone + Send + 'static> Checks<K, A> {
/// The last answer, or `None` if there has never been one. Distinct
/// from an answer that happens to be falsy: never-asked and
/// asked-and-told-no are different things to show.
pub fn answer<Q>(&self, key: &Q) -> Option<A>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
self.0
.lock()
.unwrap()
.get(key)
.and_then(|entry| entry.answer.clone())
}
/// Something is being worked out for this key right now.
pub fn is_checking<Q>(&self, key: &Q) -> bool
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
self.0
.lock()
.unwrap()
.get(key)
.is_some_and(|entry| entry.in_flight)
}
/// Why the last attempt produced no answer, if it produced none.
/// `None` both before anything has been asked and after an attempt
/// that worked, since neither is something to warn about.
pub fn error<Q>(&self, key: &Q) -> Option<String>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
self.0
.lock()
.unwrap()
.get(key)
.and_then(|entry| entry.error.clone())
}
/// Records something this server just caused, so a display reflects an
/// action without waiting for the next background attempt.
///
/// A change applied in place under one lock rather than a whole answer
/// handed in, because an answer can have more than one part and a
/// caller usually knows only the part it changed -- overwriting the
/// rest with a fresh default would throw away what was still true.
///
/// Clears the error: what is recorded here is known, so nothing is
/// left for a previous failure to qualify.
///
/// Deliberately leaves `in_flight` alone. Clearing it would let a
/// second worker start for something already being worked on, and the
/// running one would then write its now-stale answer over this.
pub fn update(&self, key: K, change: impl FnOnce(&mut A))
where
A: Default,
{
let mut map = self.0.lock().unwrap();
let entry = map.entry(key).or_default();
change(entry.answer.get_or_insert_with(A::default));
entry.error = None;
}
/// Forgets everything whose key `keep` rejects, for something being
/// removed.
pub fn retain(&self, keep: impl FnMut(&K) -> bool) {
let mut keep = keep;
self.0.lock().unwrap().retain(|key, _| keep(key));
}
/// Starts working one key out, unless it already is being. Returns at
/// once.
///
/// `work` is handed the previous answer so it can build on it -- which
/// is what lets a partial failure keep the half it still knows rather
/// than having to choose between the whole answer and none of it.
///
/// The lock is taken twice and held across neither the spawn nor the
/// work: once to claim the key, and once to record what came back.
pub fn start<W>(self: &Arc<Self>, key: K, work: W)
where
W: FnOnce(Option<A>) -> Report<A> + Send + 'static,
{
let previous = {
let mut map = self.0.lock().unwrap();
let entry = map.entry(key.clone()).or_default();
if entry.in_flight {
// Already being asked; nothing to add by asking twice.
return;
}
entry.in_flight = true;
entry.answer.clone()
};
let checks = Arc::clone(self);
std::thread::spawn(move || {
let report = work(previous);
let mut map = checks.0.lock().unwrap();
let entry = map.entry(key).or_default();
if let Some(answer) = report.answer {
entry.answer = Some(answer);
}
entry.error = report.error;
entry.in_flight = false;
});
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::*;
fn settle<K: Eq + Hash + Clone + Send + 'static, A: Clone + Send + 'static>(
checks: &Arc<Checks<K, A>>,
key: &K,
) {
let deadline = Instant::now() + Duration::from_secs(5);
while checks.is_checking(key) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(!checks.is_checking(key), "the worker never finished");
}
#[test]
fn an_answer_lands_after_the_call_that_asked_for_it() {
let checks: Arc<Checks<String, bool>> = Arc::default();
let key = "one".to_string();
// Nothing asked yet is not the same as an answer of false.
assert_eq!(checks.answer(key.as_str()), None);
assert!(!checks.is_checking(key.as_str()));
checks.start(key.clone(), |_| Report::answered(true));
settle(&checks, &key);
assert_eq!(checks.answer(key.as_str()), Some(true));
assert_eq!(checks.error(key.as_str()), None);
}
/// The failure this whole arrangement exists to make visible: a failed
/// attempt must keep the last answer *and* say that it is not current,
/// rather than silently reading as "asked, nothing to report".
#[test]
fn a_failure_keeps_the_last_answer_and_records_why() {
let checks: Arc<Checks<String, bool>> = Arc::default();
let key = "one".to_string();
checks.start(key.clone(), |_| Report::answered(true));
settle(&checks, &key);
checks.start(key.clone(), |_| Report::failed("unreachable".to_string()));
settle(&checks, &key);
assert_eq!(
checks.answer(key.as_str()),
Some(true),
"the last known answer is what to show"
);
assert_eq!(checks.error(key.as_str()), Some("unreachable".to_string()));
// And an attempt that works clears the qualification.
checks.start(key.clone(), |_| Report::answered(false));
settle(&checks, &key);
assert_eq!(checks.answer(key.as_str()), Some(false));
assert_eq!(checks.error(key.as_str()), None);
}
/// A worker can build on what was already known, which is what lets a
/// partial failure keep the half it still has.
#[test]
fn a_worker_is_handed_the_previous_answer() {
let checks: Arc<Checks<String, u32>> = Arc::default();
let key = "one".to_string();
checks.start(key.clone(), |previous| {
assert_eq!(previous, None);
Report::answered(1)
});
settle(&checks, &key);
checks.start(key.clone(), |previous| {
Report::answered(previous.expect("the first answer") + 1)
});
settle(&checks, &key);
assert_eq!(checks.answer(key.as_str()), Some(2));
}
/// Two requests arriving together must not produce two workers. The
/// second is dropped rather than queued: it would ask the same
/// question and the first is already asking it.
#[test]
fn a_second_request_does_not_start_a_second_worker() {
let checks: Arc<Checks<String, u32>> = Arc::default();
let key = "one".to_string();
let started = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
for _ in 0..5 {
let started = Arc::clone(&started);
let release = Arc::clone(&release);
checks.start(key.clone(), move |_| {
started.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
while !release.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(1));
}
Report::answered(1)
});
}
assert!(checks.is_checking(key.as_str()));
release.store(true, std::sync::atomic::Ordering::SeqCst);
settle(&checks, &key);
assert_eq!(started.load(std::sync::atomic::Ordering::SeqCst), 1);
}
/// Recording an answer must not clear the in-flight flag: doing so
/// would let a second worker start beside the running one, and the
/// running one would then overwrite this with something older.
#[test]
fn updating_an_answer_leaves_a_running_worker_claimed() {
let checks: Arc<Checks<String, u32>> = Arc::default();
let key = "one".to_string();
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
let held = Arc::clone(&release);
checks.start(key.clone(), move |_| {
while !held.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(1));
}
Report::answered(1)
});
checks.update(key.clone(), |answer| *answer = 99);
assert_eq!(checks.answer(key.as_str()), Some(99));
assert!(
checks.is_checking(key.as_str()),
"the worker is still running and must stay claimed"
);
release.store(true, std::sync::atomic::Ordering::SeqCst);
settle(&checks, &key);
}
#[test]
fn retain_forgets_what_it_rejects() {
let checks: Arc<Checks<String, u32>> = Arc::default();
checks.update("keep".to_string(), |answer| *answer = 1);
checks.update("drop".to_string(), |answer| *answer = 2);
checks.retain(|key| key != "drop");
assert_eq!(checks.answer("keep"), Some(1));
assert_eq!(checks.answer("drop"), None);
}
}
+1127
View File
File diff suppressed because it is too large. Load diff
+446
View File
@@ -0,0 +1,446 @@
//! Finding an app's built APKs given only the path to its *project*, and
//! finding candidate projects given a repo root.
//!
//! # Why fixed path shapes rather than a general search
//!
//! A general recursive search is far too slow to sit behind an interactive
//! "add an app" screen. Measured against one real 28 GB / 39k-file Rust +
//! Android project, warm cache:
//!
//! | approach | time |
//! |---------------------------------------------------|--------|
//! | `find -name '*.apk'` | 406 ms |
//! | the same, depth-limited to 8..16 | 305 ms |
//! | the same, pruning `.git`/`deps`/`.fingerprint`/... | 132 ms |
//! | the patterns below | 6 ms |
//!
//! Depth limits buy nothing because the breadth is in shallow Cargo/Gradle
//! output directories, and pruning by name still leaves an order of
//! magnitude to make up -- on a warm cache, at that. So this matches the
//! handful of shapes Android build tooling actually emits into.
//!
//! The patterns stay cheap because every literal segment is a `stat` rather
//! than a directory listing: only a `*` costs a `read_dir`, and only ever of
//! one level. Adding a pattern for a build system not covered here is the
//! intended way to extend this -- see [`APK_PATTERNS`].
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Where Android build tooling puts APKs, relative to a project root. `*`
/// matches one path segment.
///
/// Covers, in order: a single-module Gradle project; the usual one- and
/// two-level module layouts (`app/`, `androidApp/`, `composeApp/`, and
/// nested variants including React Native / Capacitor's `android/app/`);
/// Flutter; and dioxus-cli's generated Android project under a Cargo
/// `target/` directory.
const APK_PATTERNS: &[&str] = &[
"build/outputs/apk/*/*.apk",
"*/build/outputs/apk/*/*.apk",
"*/*/build/outputs/apk/*/*.apk",
"build/app/outputs/flutter-apk/*.apk",
"target/dx/*/*/android/app/app/build/outputs/apk/*/*.apk",
];
/// Marker files that make a directory worth treating as an app project when
/// scanning a repo root. Deliberately loose -- a false positive costs one
/// wasted [`find_apks`] call (microseconds), while a false negative means
/// the project never gets suggested at all.
const PROJECT_MARKERS: &[&str] = &[
"settings.gradle",
"settings.gradle.kts",
"build.gradle",
"build.gradle.kts",
"Dioxus.toml",
"pubspec.yaml",
];
/// Directory names never worth expanding a `*` into. `node_modules` and
/// `.git` are the expensive ones; hidden directories are skipped wholesale
/// below since no build system emits APKs into one.
const SKIP_DIRS: &[&str] = &["node_modules", "Pods", "vendor"];
/// A built APK found under a project, with the mtime used both to pick a
/// default among several and to tell the app whether the installed copy is
/// behind (see `crate::routes`'s manifest).
#[derive(Debug, Clone)]
pub struct ApkCandidate {
pub path: PathBuf,
pub modified: SystemTime,
/// The build variant, taken from the containing directory name
/// (`debug`, `release`, `freeRelease`, ...) -- what the app shows when
/// offering a choice between several.
pub variant: String,
}
/// Every APK built under `project`, newest first.
///
/// Derived artifacts this server itself produced (`*.slim.apk`, see
/// `crate::strip`) and unsigned intermediates are filtered out: neither is
/// a build output a caller would ever mean to select, and offering the slim
/// copy of an APK beside that APK would be two entries for one build.
pub fn find_apks(project: &Path) -> Vec<ApkCandidate> {
let mut found = Vec::new();
for pattern in APK_PATTERNS {
for path in expand(project, pattern) {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.ends_with(".slim.apk") || name.ends_with("-unsigned.apk") {
continue;
}
let Ok(modified) = std::fs::metadata(&path).and_then(|meta| meta.modified()) else {
continue;
};
let variant = path
.parent()
.and_then(|parent| parent.file_name())
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
found.push(ApkCandidate {
path,
modified,
variant,
});
}
}
// A project can match more than one pattern (a Gradle project whose
// root is also a module), so the same file can be found twice.
found.sort_by(|a, b| a.path.cmp(&b.path));
found.dedup_by(|a, b| a.path == b.path);
found.sort_by_key(|apk| std::cmp::Reverse(apk.modified));
found
}
/// A project directory found under a configured repo root that the "add an
/// app" screen can offer: one with a build under it, or one carrying a
/// declaration of its own, which can be added before its first build.
#[derive(Debug, Clone)]
pub struct ProjectSuggestion {
pub path: PathBuf,
/// Display name: the project's path relative to the root it was found
/// under. Not just the directory's own name, because the interesting
/// ones are routinely generic -- two repos each with an `app/` produce
/// two suggestions indistinguishable by name alone, where `foo/app` and
/// `bar/app` are not. The real label is read out of the APK once the
/// project is actually added (`crate::apkinfo`), which costs a
/// subprocess per APK and so isn't done for every suggestion.
pub name: String,
pub apk_count: usize,
pub newest: SystemTime,
}
/// Every project under `roots` worth offering, newest first -- by build
/// where there is one, and by when the project declared itself where there
/// isn't.
///
/// Descends two levels below each root, which covers both a root holding
/// projects directly and one holding multi-project repositories (a repo
/// with `app/` and `app-dioxus/` side by side). Deeper than that costs
/// fanout for a layout nobody uses, and a project nested further can always
/// be added by typing its path.
pub fn scan_roots(roots: &[PathBuf]) -> Vec<ProjectSuggestion> {
let mut suggestions = Vec::new();
let mut seen = std::collections::HashSet::new();
for root in roots {
for depth1 in child_dirs(root) {
consider(&depth1, root, &mut suggestions, &mut seen);
// A declaration ends the descent: it says "this directory is
// the project", so whatever is underneath is that project's
// own layout rather than another project to offer. Without
// this, a repository declaring itself at the root and building
// through a Gradle module underneath came back twice -- the
// root matched through the one-level APK pattern, the module
// through its own marker and the same build, and the two are
// different directories so nothing deduplicated them.
//
// A genuinely separate project nested inside a declared one
// can still be added by typing its path, which is the escape
// hatch for every layout this scan does not cover.
if declaration_of(&depth1).is_some() {
continue;
}
for depth2 in child_dirs(&depth1) {
consider(&depth2, root, &mut suggestions, &mut seen);
}
}
}
suggestions.sort_by_key(|item| std::cmp::Reverse(item.newest));
suggestions
}
/// The declaration this directory carries, if it carries one.
///
/// The path rather than a bare yes, because a project offered before its
/// first build is sorted by when it said so and there is nothing else to
/// date it by.
fn declaration_of(dir: &Path) -> Option<PathBuf> {
let path = dir.join(crate::config::PROJECT_CONFIG_FILE);
path.is_file().then_some(path)
}
fn consider(
dir: &Path,
root: &Path,
suggestions: &mut Vec<ProjectSuggestion>,
seen: &mut std::collections::HashSet<PathBuf>,
) {
// A project carrying this server's own config file has said outright
// that it is one, which is a stronger claim than any build-system
// marker guessed at below.
let declaration = declaration_of(dir);
let declared = declaration.is_some();
if !declared
&& !PROJECT_MARKERS
.iter()
.any(|marker| dir.join(marker).is_file())
{
return;
}
let apks = find_apks(dir);
let newest = match apks.first() {
Some(apk) => apk.modified,
// Offered with nothing built, because a project that declares
// itself usually declares a build step too -- and that step is
// frequently the thing that produces the first APK, so requiring
// one first is a chicken-and-egg. Sorted by when it said so, which
// keeps a freshly declared project among the recent ones instead
// of at the bottom forever.
None => match &declaration {
Some(path) => std::fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH),
None => return,
},
};
if !seen.insert(dir.to_path_buf()) {
return;
}
suggestions.push(ProjectSuggestion {
path: dir.to_path_buf(),
name: dir
.strip_prefix(root)
.unwrap_or(dir)
.to_string_lossy()
.into_owned(),
apk_count: apks.len(),
newest,
});
}
/// Immediate subdirectories of `dir`, minus hidden ones and the known-huge
/// names in [`SKIP_DIRS`]. Symlinks are not followed -- a repo root full of
/// symlinks into each other would otherwise turn a bounded scan unbounded.
fn child_dirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
.map(|entry| entry.path())
.filter(|path| !is_skipped(path))
.collect()
}
fn is_skipped(path: &Path) -> bool {
let Some(name) = path.file_name().map(|name| name.to_string_lossy()) else {
return true;
};
name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref())
}
/// Expands a `/`-separated pattern against `base`, one segment at a time.
/// A literal segment is appended without touching the filesystem; only `*`
/// and `*.apk`-style segments read a directory, and only that one level.
/// The final existence check happens in [`find_apks`]'s `metadata` call, so
/// a literal-only pattern costs nothing until then.
fn expand(base: &Path, pattern: &str) -> Vec<PathBuf> {
let mut current = vec![base.to_path_buf()];
for segment in pattern.split('/') {
let mut next = Vec::new();
if let Some(suffix) = segment.strip_prefix('*') {
for dir in &current {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.filter_map(|entry| entry.ok()) {
let path = entry.path();
if is_skipped(&path) {
continue;
}
if suffix.is_empty() || path.to_string_lossy().ends_with(suffix) {
next.push(path);
}
}
}
} else {
next.extend(current.iter().map(|dir| dir.join(segment)));
}
if next.is_empty() {
return Vec::new();
}
current = next;
}
current
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a tree of empty files from `/`-separated relative paths.
fn tree(paths: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for path in paths {
let full = dir.path().join(path);
std::fs::create_dir_all(full.parent().expect("has a parent")).expect("mkdir");
std::fs::write(&full, b"").expect("write");
}
dir
}
fn names(apks: &[ApkCandidate]) -> Vec<String> {
apks.iter()
.map(|apk| apk.path.file_name().unwrap().to_string_lossy().into_owned())
.collect()
}
#[test]
fn finds_apks_across_the_supported_module_layouts() {
let root = tree(&[
"build/outputs/apk/debug/root-debug.apk",
"app/build/outputs/apk/debug/one-level.apk",
"androidApp/nested/build/outputs/apk/debug/two-level.apk",
"build/app/outputs/flutter-apk/app-debug.apk",
"target/dx/demo/debug/android/app/app/build/outputs/apk/debug/dx.apk",
]);
let mut found = names(&find_apks(root.path()));
found.sort();
assert_eq!(
found,
[
"app-debug.apk",
"dx.apk",
"one-level.apk",
"root-debug.apk",
"two-level.apk"
]
);
}
#[test]
fn ignores_this_servers_own_derived_artifacts() {
// The slim copy sits beside the APK it was derived from; offering
// both would be two entries for one build.
let root = tree(&[
"app/build/outputs/apk/debug/app-debug.apk",
"app/build/outputs/apk/debug/app-debug.apk.slim.apk",
"app/build/outputs/apk/release/app-release-unsigned.apk",
]);
assert_eq!(names(&find_apks(root.path())), ["app-debug.apk"]);
}
#[test]
fn reports_the_variant_directory_and_orders_newest_first() {
let root = tree(&[
"app/build/outputs/apk/debug/app-debug.apk",
"app/build/outputs/apk/freeRelease/app-freeRelease.apk",
]);
// mtimes from a single `tree` call are too close together to order
// reliably, so make the intended winner explicitly newer.
let newer = root
.path()
.join("app/build/outputs/apk/freeRelease/app-freeRelease.apk");
let stamp = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
filetime::set_file_mtime(&newer, filetime::FileTime::from_system_time(stamp))
.expect("set mtime");
let found = find_apks(root.path());
assert_eq!(found[0].variant, "freeRelease");
assert_eq!(found[1].variant, "debug");
}
#[test]
fn skips_directories_that_are_never_worth_walking() {
let root = tree(&[
"node_modules/pkg/build/outputs/apk/debug/vendored.apk",
".git/build/outputs/apk/debug/hidden.apk",
]);
assert!(find_apks(root.path()).is_empty());
}
#[test]
fn suggests_only_marked_projects_that_have_actually_been_built() {
let root = tree(&[
// Two levels down, the layout a multi-project repo has.
"repo/built/settings.gradle.kts",
"repo/built/app/build/outputs/apk/debug/built.apk",
// Marked, but nothing built yet.
"repo/unbuilt/settings.gradle.kts",
// Built, but not recognizable as a project root.
"repo/unmarked/app/build/outputs/apk/debug/stray.apk",
]);
let found = scan_roots(&[root.path().to_path_buf()]);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].name, "repo/built");
assert_eq!(found[0].apk_count, 1);
}
#[test]
fn suggests_a_project_sitting_directly_under_a_root() {
let root = tree(&[
"solo/build.gradle.kts",
"solo/build/outputs/apk/debug/solo.apk",
]);
let found = scan_roots(&[root.path().to_path_buf()]);
assert_eq!(found.len(), 1);
assert_eq!(found[0].name, "solo");
}
/// The chicken-and-egg case: a project whose build step is what
/// produces the first APK has to be addable before there is one, so
/// declaring itself is enough to be offered.
#[test]
fn a_project_that_declares_itself_is_offered_before_its_first_build() {
let root = tree(&[
"repo/declared/.dev-updater.ron",
// No marker file and nothing built -- the declaration is the
// only reason this is a project at all.
"repo/silent/src/main.rs",
]);
let found = scan_roots(&[root.path().to_path_buf()]);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].name, "repo/declared");
assert_eq!(found[0].apk_count, 0);
}
/// A project that declares itself is *the* project, and must be
/// offered once. Before this, a repository with a declaration at its
/// root and a Gradle module underneath produced two suggestions for
/// one app: the root matched through the one-level APK pattern, the
/// module matched through its own marker and its own build, and
/// nothing deduplicated them because they are different directories.
#[test]
fn a_declared_project_is_not_offered_again_through_its_module() {
let root = tree(&[
"repo/.dev-updater.ron",
"repo/app/build.gradle.kts",
"repo/app/build/outputs/apk/debug/app-debug.apk",
]);
let found = scan_roots(&[root.path().to_path_buf()]);
assert_eq!(found.len(), 1, "one app, one suggestion: {found:?}");
assert_eq!(found[0].name, "repo", "the declaration names the project");
assert_eq!(
found[0].apk_count, 1,
"and it still finds the build below it"
);
}
#[test]
fn a_missing_root_is_not_an_error() {
assert!(scan_roots(&[PathBuf::from("/definitely/not/here")]).is_empty());
}
}
+810
View File
@@ -0,0 +1,810 @@
//! What the checkout under a project looks like, and bringing it up to
//! date.
//!
//! Uses the `git` binary rather than a Rust git library: it is already on
//! any machine that produced these projects, it reads the same config,
//! credentials, and remotes the person uses by hand, and there is no
//! second implementation of "what does this repo consider upstream" to
//! disagree with the one they debug with -- which is what prefers what
//! is already here over a new dependency.
//!
//! The split that matters here is network versus not. [`status`] only
//! reads what is already on disk, so it is cheap enough to answer on every
//! manifest request; it reports the branch as of the last fetch, which is
//! exactly what "3 commits behind" means everywhere else in git. [`fetch`]
//! is the one call that talks to a remote, and happens only when a person
//! asks for an update.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// A project's checkout, as of the last fetch.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitStatus {
/// The checked-out branch, or `HEAD` when detached.
pub branch: String,
/// Uncommitted changes, which make pulling something this shouldn't
/// decide on its own.
pub dirty: bool,
/// `origin/main` and the like; absent for a branch that tracks
/// nothing, in which case there is nothing to pull.
pub upstream: Option<String>,
/// The top of the checkout. What gets pulled, and what a person calls
/// the project -- a card showing `~/repos/thing/app` is naming the
/// directory this server watches, not the repository it lives in.
///
/// Contracted for display here rather than at the one place that shows
/// it, because that is the only thing it is for; nothing on this side
/// opens it.
pub root: String,
}
/// Reads `project`'s checkout without touching the network. `None` when
/// it isn't in a git repository at all, which is not an error -- a project
/// can perfectly well be a directory somebody builds by hand.
pub fn status(project: &Path) -> Option<GitStatus> {
// Doubles as the "is this a repo at all" question, which is why it is
// first and why its failure is a plain `None`.
let toplevel = git(project, &["rev-parse", "--show-toplevel"]).ok()?;
let root = crate::config::contract_tilde(Path::new(&toplevel));
let branch = git(project, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let upstream = git(project, &["rev-parse", "--abbrev-ref", "@{u}"]).ok();
let dirty = git(project, &["status", "--porcelain"])
.map(|out| !out.is_empty())
.unwrap_or(false);
Some(GitStatus {
branch,
dirty,
upstream,
root,
})
}
/// Whether anything under `within` is edited or untracked, relative to
/// `project`. `None` when the checkout cannot be read.
///
/// Scoped for the same reason `subtree_head` is: a whole-checkout answer
/// would put every component of a one-checkout-two-things project into the
/// same state on any edit, which is the daily annoyance the scoping exists
/// to avoid. An edit under `app/` is about the app.
///
/// Untracked files count. A new source file genuinely changes what a build
/// produces, and `.gitignore` has already filtered out the editor noise
/// and the build outputs, so what is left is real. This also answers the
/// submodule case correctly and without a special case: a submodule whose
/// working tree has moved shows as a modified path, so whichever component
/// contains it goes unknown and the others do not.
pub fn subtree_dirty(project: &Path, within: Option<&Path>) -> Option<bool> {
let mut args = vec!["status", "--porcelain", "--"];
let within = within.map(|path| path.to_string_lossy().into_owned());
args.push(within.as_deref().unwrap_or("."));
git(project, &args).ok().map(|out| !out.trim().is_empty())
}
/// The newest commit touching `within` (a path relative to `project`), or
/// the whole checkout when `within` is `None`.
///
/// This is what a build is recorded against, and the scoping is the point.
/// One checkout routinely produces several things -- a backend under
/// `server/` and an APK under `app/` -- and comparing both against `HEAD`
/// would mark the backend stale for a commit that only touched the app,
/// offering a pointless rebuild on every app-only change. Asking what last
/// touched the component's own directory makes "out of date" mean what a
/// person reading the card expects it to.
///
/// `None` when the checkout cannot be read, which is "we cannot tell"
/// rather than an answer; see `BuildState::freshness`.
pub fn subtree_head(project: &Path, within: Option<&Path>) -> Option<String> {
let mut args = vec!["log", "-1", "--format=%H"];
let within = within.map(|path| path.to_string_lossy().into_owned());
if let Some(path) = within.as_deref() {
args.push("--");
args.push(path);
}
let sha = git(project, &args).ok()?;
(!sha.is_empty()).then_some(sha)
}
/// Commits the upstream branch has that this one doesn't, counted from
/// refs already on disk. Only meaningful right after a fetch, which is why
/// the only caller is the pull itself.
pub fn behind(project: &Path) -> usize {
git(project, &["rev-list", "--count", "HEAD..@{u}"])
.ok()
.and_then(|count| count.parse().ok())
.unwrap_or(0)
}
/// Whether pulling this checkout would actually move it.
///
/// Read-only: `ls-remote` asks the remote what it has without writing
/// anything into the checkout, so this can run on a timer behind every
/// manifest request. Fetching would be the mutating half, and that is
/// `pull`'s job.
///
/// The question is asked as "would a fast-forward move HEAD?", i.e. is
/// the remote's commit already an ancestor of what is checked out. The
/// obvious comparison -- remote tip against the remote-tracking ref --
/// answers a different question and gets two cases wrong: a checkout that
/// has fetched but not merged looks current when a pull would move it,
/// and a checkout carrying local commits looks behind when a pull would
/// do nothing.
///
/// A remote commit that isn't in the object store yet makes
/// `--is-ancestor` fail, which is the right answer for the right reason:
/// a commit we don't have is by definition new.
pub fn has_new_commits(project: &Path, ipv4: bool) -> Result<bool, String> {
let upstream = git(project, &["rev-parse", "--abbrev-ref", "@{u}"])
.map_err(|_| "branch tracks no upstream".to_string())?;
let (remote, branch) = upstream
.split_once('/')
.ok_or_else(|| format!("cannot tell a remote from a branch in {upstream}"))?;
let listed = run_bounded(
project,
ipv4,
&["ls-remote", remote, &format!("refs/heads/{branch}")],
)?;
// "<sha>\trefs/heads/<branch>"; empty when the branch is gone.
let Some(remote_sha) = listed.split_whitespace().next() else {
return Err(format!("{remote} has no branch {branch}"));
};
Ok(git(
project,
&["merge-base", "--is-ancestor", remote_sha, "HEAD"],
)
.is_err())
}
/// Updates the remote-tracking refs. The one call here that uses the
/// network, and therefore the one that can hang: a black-holed host makes
/// a bare `git fetch` sit for minutes, which is long enough to be
/// indistinguishable from the server being down.
///
/// Bounded twice over. `ConnectTimeout` caps the part that actually
/// hangs, and `BatchMode` makes a key that wants a passphrase fail rather
/// than wait for a person who isn't there. Then the whole thing is killed
/// at [`REMOTE_HARD_TIMEOUT`] regardless, so nothing accumulates when a
/// remote is merely very slow.
pub fn fetch(project: &Path, ipv4: bool) -> Result<(), String> {
// `fetch` is one of the few git subcommands that takes the flag
// itself, and that is what carries the preference to an https remote.
// An ssh remote is covered by `ssh_command` either way.
let mut args = vec!["fetch", "--quiet"];
if ipv4 {
args.insert(1, "-4");
}
run_bounded(project, ipv4, &args).map(|_| ())
}
/// How git should invoke ssh: the two bounds every remote command needs,
/// and IPv4 only when this project asked for it.
///
/// The address family is set *here* rather than on the git subcommand
/// because most subcommands have no such option. `git fetch` takes `-4`;
/// `git ls-remote` does not, and rejects it with "unknown switch" -- and
/// `ls-remote` is what the new-commits check runs, so a flag spliced onto
/// every command would have broken exactly the thing the card reports.
/// The ssh invocation covers every remote command uniformly, which is what
/// a setting called "force IPv4" has to do to be worth having.
fn ssh_command(ipv4: bool) -> String {
let family = if ipv4 { " -4" } else { "" };
format!("ssh{family} -o ConnectTimeout=5 -o BatchMode=yes")
}
/// Runs a git command that talks to a remote, with the two bounds such a
/// command needs: `ConnectTimeout` for a host that never answers,
/// `BatchMode` so a key wanting a passphrase fails instead of waiting for
/// somebody who isn't there, and a hard stop for a remote that connects
/// and then stalls.
fn run_bounded(project: &Path, ipv4: bool, args: &[&str]) -> Result<String, String> {
let mut child = Command::new("git")
.arg("-C")
.arg(project)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
// Appended rather than replaced by git, so a custom ssh setup in
// the user's config still applies.
.env("GIT_SSH_COMMAND", ssh_command(ipv4))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run git {}: {err}", args[0]))?;
let deadline = Instant::now() + REMOTE_HARD_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(_)) => {
let output = child
.wait_with_output()
.map_err(|err| format!("reading git output: {err}"))?;
return if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(remote_failure(project, args[0], stderr.trim()))
};
}
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"git {} took longer than {}s and was stopped",
args[0],
REMOTE_HARD_TIMEOUT.as_secs()
));
}
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(err) => return Err(format!("waiting on git {}: {err}", args[0])),
}
}
}
/// Fast-forwards the current branch onto its upstream.
///
/// `--ff-only` deliberately: a merge or a rebase can conflict, and
/// resolving a conflict is not something to start from a phone with no way
/// to finish it. Refusing to touch a dirty tree is the same reasoning --
/// the failure is reported and the working tree is left exactly as it was.
pub fn pull(project: &Path, ipv4: bool) -> Result<(), String> {
let status = status(project).ok_or_else(|| "not a git repository".to_string())?;
if status.upstream.is_none() {
return Err(format!(
"branch {} tracks no upstream, so there is nothing to pull",
status.branch
));
}
if status.dirty {
return Err(format!(
"{} has uncommitted changes -- refusing to pull over them. Commit or stash on the \
build machine first.",
project.display()
));
}
git(project, &["merge", "--ff-only", "@{u}"])?;
// A merge moves the *pointer* a submodule is recorded at without
// touching its working tree, so a pull that updated one leaves the
// build compiling the old contents -- which is precisely the drift a
// submodule is here to prevent, arriving quietly. `git pull
// --recurse-submodules` would cover it, but this fetches and merges
// separately so that the two failures can be told apart, and `merge`
// has no such flag.
//
// A no-op in a repository with no submodules, which is most of them,
// and not fatal in one where it fails: the merge already landed, so
// reporting the whole pull as failed would be worse than saying the
// submodule is behind.
//
// Through `run_bounded` rather than `git`, because this one talks to a
// remote: it needs the address-family preference in `GIT_SSH_COMMAND`
// -- `git submodule` has no `-4` of its own to splice on -- and it
// needs the timeout, since a clone that hangs on an unreachable
// address would otherwise hang the request behind it with no way to
// tell what it was waiting for.
if let Err(message) = run_bounded(
project,
ipv4,
&["submodule", "update", "--init", "--recursive"],
) {
tracing::warn!("pulled, but updating submodules failed: {message}");
}
Ok(())
}
/// The phone-sized version of a failed remote command: the first line git
/// printed, plus what this process can see of the ssh agent when what
/// failed was authentication.
///
/// Shortened here rather than at each place that displays it, so there is
/// one rule for what a remote failure looks like instead of one per
/// caller. Git's remote failures run to a paragraph of advice about access
/// rights and whether the repository exists -- true, generic, and several
/// lines longer than a card on a phone should carry. The whole of it goes
/// to the log, which is where someone standing at the build machine can
/// read it.
fn remote_failure(project: &Path, command: &str, stderr: &str) -> String {
tracing::warn!("git {command} in {} failed: {stderr}", project.display());
let headline = stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(stderr)
.to_string();
match agent_note(stderr) {
Some(note) => format!("{headline}\n\n{note}"),
None => headline,
}
}
/// What this process can see of the ssh agent, for a remote command the
/// far end refused.
///
/// This is the whole difference between "it works when I run it myself"
/// and "it fails from the service": an agent is reached through
/// `SSH_AUTH_SOCK`, which is inherited, and a daemon started by an init
/// system is not a child of the login shell that started the agent. That
/// is invisible from a phone, and it is the first thing anyone would check
/// at a terminal, so the answer travels with the failure.
///
/// Measured rather than guessed. The agent is asked and what it says is
/// what is reported, so the note can say the agent is fine -- which is the
/// answer that stops this being blamed for a key the remote simply doesn't
/// accept. `ssh-add` is a process spawn, so this is reached only from a
/// failure that has already paid for a network round trip; nothing on the
/// manifest path runs it.
fn agent_note(stderr: &str) -> Option<String> {
if !stderr.contains("Permission denied") && !stderr.contains("publickey") {
return None;
}
let Ok(socket) = std::env::var("SSH_AUTH_SOCK") else {
return Some(
"This server has no SSH_AUTH_SOCK, so ssh had no agent to ask. A service started \
by an init system doesn't inherit the agent a login shell starts -- it has to be \
given the path to one."
.to_string(),
);
};
// ssh-add's exit status is the whole answer: 0 having listed
// identities, 1 having reached an agent that holds none, 2 having
// failed to reach one at all.
match Command::new("ssh-add").arg("-l").output() {
Err(err) => Some(format!(
"SSH_AUTH_SOCK is {socket}, but ssh-add wouldn't run to check it: {err}"
)),
Ok(output) => Some(match output.status.code() {
Some(0) => {
let keys = String::from_utf8_lossy(&output.stdout).lines().count();
format!(
"The agent at {socket} answered and holds {keys} {}, so this is not the \
agent being missing -- the remote refused the keys it was offered.",
if keys == 1 { "key" } else { "keys" }
)
}
Some(1) => format!(
"The agent at {socket} answered but holds no keys. Nothing has been added to \
it since it started."
),
_ => format!(
"Nothing is answering on SSH_AUTH_SOCK ({socket}), so ssh had no agent to ask."
),
}),
}
}
/// When a command talking to a remote is given up on entirely. Only
/// reached by a host that accepts a connection and then stalls; an
/// unreachable one fails at `ConnectTimeout` long before this.
const REMOTE_HARD_TIMEOUT: Duration = Duration::from_secs(30);
/// A checkout to ask about, and how to ask about it.
///
/// Carried together because the two are decided in different places -- the
/// path by the project, the flag by whoever toggled it on this machine --
/// and `refresh` needs both for each checkout it starts a thread for.
#[derive(Debug, Clone)]
pub struct Checkout {
pub path: PathBuf,
/// Pass `-4` to the commands that talk to a remote.
pub ipv4: bool,
}
/// Remembers what each checkout's remote last said, and refreshes those
/// answers **off the request path**.
///
/// Asking a remote costs a round trip, and the answer is decoration: the
/// list is about which apps have builds and whether the phone has them.
/// Making the list wait for it meant every reopen paid for it -- so a
/// check runs in the background and the answer lands on the next look.
///
/// There is no time-based throttle. A check is a single `ls-remote`, which
/// costs a fraction of a second, and the thing it guards against -- the
/// list being asked for repeatedly -- only happens when a person opens,
/// resumes or refreshes the app. Rationing the answer to once per interval
/// bought little and meant a push made just after a check went unnoticed
/// for the rest of it. The one rule left is that a checkout never has two
/// checks running at once, and asking for a check is separated from asking
/// whether one is outstanding ([`Self::is_checking`]) so that polling for
/// an answer cannot itself keep starting new work.
/// Answers kept by [`crate::checks`], which is the whole of the
/// mechanism: this is the typed face of it for remotes.
#[derive(Default, Clone)]
pub struct RemoteChecks(Arc<crate::checks::Checks<PathBuf, bool>>);
impl RemoteChecks {
/// Whether this checkout's remote was last seen to have commits it
/// doesn't. `false` before anything has been asked, which is the same
/// as a card with no badge -- see [`Self::error`] for why that is safe
/// to conflate here and nowhere else: a failed check is qualified by
/// the error rather than by this.
pub fn new_commits(&self, project: &Path) -> bool {
self.0.answer(project).unwrap_or(false)
}
/// A check for this checkout is outstanding, so what
/// [`Self::new_commits`] says is provisional.
pub fn is_checking(&self, project: &Path) -> bool {
self.0.is_checking(project)
}
/// Why the last check produced no answer, if it produced none.
pub fn error(&self, project: &Path) -> Option<String> {
self.0.error(project)
}
/// Records that this checkout is level with its remote, for a pull
/// this server just did -- so the card stops offering one without
/// waiting for the next check to confirm what it already knows.
pub fn mark_current(&self, project: &Path) {
self.0
.update(project.to_path_buf(), |new_commits| *new_commits = false);
}
/// Asks every checkout that isn't already being asked. Returns at once.
pub fn refresh(&self, projects: &[Checkout]) {
for checkout in projects {
let (project, ipv4) = (checkout.path.clone(), checkout.ipv4);
self.0.start(project.clone(), move |_| {
has_new_commits(&project, ipv4).into()
});
}
}
}
/// Runs one git command in `project`, returning trimmed stdout, or
/// stderr as the error so a failure says what git said.
fn git(project: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.arg("-C")
.arg(project)
.args(args)
// Never stop for credentials: this runs with nobody at the
// terminal, and a prompt would hang the request instead of
// failing it.
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|err| format!("failed to run git: {err}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn run(dir: &Path, args: &[&str]) {
let output = Command::new(args[0])
.args(&args[1..])
.current_dir(dir)
.output()
.expect("run command");
assert!(
output.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
/// An origin repo with one commit, and a clone of it.
fn origin_and_clone(root: &Path) -> (PathBuf, PathBuf) {
let origin = root.join("origin");
std::fs::create_dir_all(&origin).expect("mkdir");
run(&origin, &["git", "init", "-q", "-b", "main"]);
run(&origin, &["git", "config", "user.email", "t@example.com"]);
run(&origin, &["git", "config", "user.name", "Test"]);
std::fs::write(origin.join("file"), "one").expect("write");
run(&origin, &["git", "add", "."]);
run(&origin, &["git", "commit", "-qm", "one"]);
let clone = root.join("clone");
run(
root,
&[
"git",
"clone",
"-q",
origin.to_str().unwrap(),
clone.to_str().unwrap(),
],
);
run(&clone, &["git", "config", "user.email", "t@example.com"]);
run(&clone, &["git", "config", "user.name", "Test"]);
(origin, clone)
}
#[test]
fn reports_nothing_for_a_directory_outside_any_repository() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(status(dir.path()), None);
}
#[test]
fn status_reports_the_checkout_without_touching_the_network() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
let fresh = status(&clone).expect("a repository");
assert_eq!(fresh.branch, "main");
assert!(!fresh.dirty);
assert_eq!(fresh.upstream.as_deref(), Some("origin/main"));
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
// Unchanged: status only reads what is already here.
assert_eq!(status(&clone).expect("status").branch, "main");
assert_eq!(behind(&clone), 0);
}
/// The bug this shape of the setting exists to avoid: `-4` spliced
/// onto every git subcommand makes `git ls-remote` fail with "unknown
/// switch", and `ls-remote` is what the check runs -- so forcing IPv4
/// would have turned every card's commit count into an error.
#[test]
fn forcing_ipv4_does_not_break_the_check() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
assert!(!has_new_commits(&clone, true).expect("check with ipv4 forced"));
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
assert!(has_new_commits(&clone, true).expect("check with ipv4 forced"));
}
/// The sibling of the test above, and the reason it exists: the
/// address-family preference governs *every* git command that reaches a
/// remote, and it was applied to the check while the pull's submodule
/// step was left out -- which hung, because `git submodule` has no `-4`
/// to splice on and nothing bounded how long it waited.
#[test]
fn forcing_ipv4_does_not_break_the_pull() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, true).expect("fetch with ipv4 forced");
pull(&clone, true).expect("pull with ipv4 forced");
assert!(!has_new_commits(&clone, true).expect("check"));
}
#[test]
fn the_ssh_command_carries_the_address_family_only_when_asked() {
assert!(!ssh_command(false).contains("-4"));
assert!(ssh_command(true).starts_with("ssh -4 "));
// The bounds are not optional; forcing a family must not drop them.
for command in [ssh_command(false), ssh_command(true)] {
assert!(command.contains("ConnectTimeout=5"), "{command}");
assert!(command.contains("BatchMode=yes"), "{command}");
}
}
/// The property the list depends on: it can tell there is something to
/// pull without downloading it or disturbing the repository.
#[test]
fn a_check_sees_new_commits_and_changes_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
assert!(!has_new_commits(&clone, false).expect("check"));
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
// Read through git rather than off disk: a fresh clone packs its
// refs, so there is no loose file to compare.
let before = git(&clone, &["rev-parse", "origin/main"]).expect("tracking ref");
assert!(
has_new_commits(&clone, false).expect("check"),
"should see the new commit"
);
// Nothing was downloaded and no ref moved -- which is what makes
// showing the list a read rather than a mutation.
let after = git(&clone, &["rev-parse", "origin/main"]).expect("tracking ref");
assert_eq!(before, after, "the check must not move the tracking ref");
assert!(
!clone.join(".git/FETCH_HEAD").exists(),
"the check must not fetch"
);
assert_eq!(
behind(&clone),
0,
"no objects were downloaded to count against"
);
// Pull is what actually takes them.
fetch(&clone, false).expect("fetch");
assert_eq!(behind(&clone), 1);
pull(&clone, false).expect("pull");
assert!(!has_new_commits(&clone, false).expect("check"));
}
/// Only an authentication failure gets the agent note -- an
/// unreachable host is a different problem, and answering it with
/// "your agent is fine" would send someone the wrong way.
#[test]
fn only_a_refused_key_is_explained_by_the_agent() {
assert!(
agent_note("fatal: unable to access: Could not resolve host: example.invalid")
.is_none()
);
assert!(agent_note("git@host: Permission denied (publickey).").is_some());
}
/// The two cases a remote-tip-vs-tracking-ref comparison gets wrong:
/// having already fetched doesn't mean the commits have been taken,
/// and having local commits of your own doesn't mean you are behind.
#[test]
fn the_question_is_whether_a_pull_would_move_head() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, false).expect("fetch");
assert!(
has_new_commits(&clone, false).expect("check"),
"fetched but not merged is still something to pull",
);
pull(&clone, false).expect("pull");
run(
&clone,
&["git", "commit", "-q", "--allow-empty", "-m", "local"],
);
assert!(
!has_new_commits(&clone, false).expect("check"),
"a commit of one's own is not the remote being ahead",
);
}
/// The list must never wait on a remote. This is the whole reason the
/// checks moved off the request path: reopening the app was stalling
/// behind a round trip whose answer is decoration.
#[test]
fn refreshing_returns_at_once_and_the_answer_lands_after() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
let checks = Arc::new(RemoteChecks::default());
let projects = vec![Checkout {
path: clone.clone(),
ipv4: false,
}];
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
let started = Instant::now();
checks.refresh(&projects);
assert!(
started.elapsed() < Duration::from_millis(100),
"refresh waited for the check instead of starting it",
);
assert!(
checks.is_checking(&clone),
"a first look has an answer outstanding"
);
// Not known yet, and said so rather than guessed at.
assert!(!checks.new_commits(&clone));
// A second look while the first is still running must not pile on.
checks.refresh(&projects);
settle(&checks, &projects);
assert!(
checks.new_commits(&clone),
"the answer should have landed by now"
);
}
/// A push made moments after a check is seen on the very next look.
///
/// There is no interval over which the previous answer is reused. That
/// used to be thirty seconds, which was exactly long enough for a push
/// you had just made to look like nothing had happened.
#[test]
fn a_push_right_after_a_check_is_seen_on_the_next_look() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
let checks = Arc::new(RemoteChecks::default());
let projects = vec![Checkout {
path: clone.clone(),
ipv4: false,
}];
checks.refresh(&projects);
settle(&checks, &projects);
assert!(!checks.new_commits(&clone), "nothing pushed yet");
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
// Straight away, with no waiting out a window.
checks.refresh(&projects);
settle(&checks, &projects);
assert!(
checks.new_commits(&clone),
"the push should be visible on the next look"
);
}
/// Waiting for a running check, the way a poll does: with
/// `is_checking`, never `refresh`. Polling with `refresh` would start
/// another check each time and so always find one outstanding.
fn settle(checks: &Arc<RemoteChecks>, projects: &[Checkout]) {
let busy = || {
projects
.iter()
.any(|checkout| checks.is_checking(&checkout.path))
};
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline && busy() {
std::thread::sleep(Duration::from_millis(20));
}
assert!(!busy(), "a check did not finish in time");
}
#[test]
fn pulling_fast_forwards_and_clears_being_behind() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, false).expect("fetch");
pull(&clone, false).expect("pull");
assert_eq!(behind(&clone), 0);
assert_eq!(
std::fs::read_to_string(clone.join("file")).expect("read"),
"two"
);
}
/// The working tree is the person's, and a pull that clobbered it
/// would be discovered long after the phone tap that caused it.
#[test]
fn refuses_to_pull_over_uncommitted_changes() {
let dir = tempfile::tempdir().expect("tempdir");
let (origin, clone) = origin_and_clone(dir.path());
std::fs::write(origin.join("file"), "two").expect("write");
run(&origin, &["git", "commit", "-qam", "two"]);
fetch(&clone, false).expect("fetch");
std::fs::write(clone.join("file"), "local edit").expect("write");
assert!(status(&clone).expect("status").dirty);
let err = pull(&clone, false).expect_err("should refuse");
assert!(err.contains("uncommitted changes"), "{err}");
// Left exactly as it was.
assert_eq!(
std::fs::read_to_string(clone.join("file")).expect("read"),
"local edit"
);
}
#[test]
fn a_branch_tracking_nothing_has_nothing_to_pull() {
let dir = tempfile::tempdir().expect("tempdir");
let (_origin, clone) = origin_and_clone(dir.path());
run(&clone, &["git", "checkout", "-qb", "detached-work"]);
let status = status(&clone).expect("status");
assert_eq!(status.branch, "detached-work");
assert_eq!(status.upstream, None);
assert!(
pull(&clone, false)
.expect_err("no upstream")
.contains("no upstream")
);
// Nothing to compare against, so the list is told plainly rather
// than being handed a misleading "up to date".
assert!(has_new_commits(&clone, false).is_err());
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Reading the tail of a log file.
//!
//! Logs are read from the *end*, in chunks, rather than by loading the
//! file and taking the last lines of it. A service that has been up for a
//! week can have a log far larger than anything worth holding in memory,
//! and the interesting part is always the end.
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// How much of a log will ever be read, however many lines were asked
/// for.
///
/// Asking for "all lines" is a legitimate request and the answer still has
/// to fit in memory and in a response, so this bounds it -- and when it
/// bites, the reader is told rather than handed a silently shortened log.
const MAX_BYTES: u64 = 4 * 1024 * 1024;
/// How much is read at a time when walking backwards.
const CHUNK: u64 = 64 * 1024;
/// The tail of a log, and whether it is the whole of it.
pub struct Tail {
pub text: String,
/// The file was longer than what is here, either because more lines
/// exist or because [`MAX_BYTES`] cut it off. The distinction the
/// reader needs is only "is this everything?", and this answers it.
pub truncated: bool,
}
/// The last `lines` lines of `path`, or as much as [`MAX_BYTES`] allows
/// when `lines` is 0.
///
/// Reads backwards from the end until it has enough newlines, so the cost
/// is proportional to what was asked for rather than to the file.
pub fn tail(path: &Path, lines: usize) -> Result<Tail> {
let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
let size = file.metadata().context("stat the log")?.len();
let mut from = size;
let mut buffer: Vec<u8> = Vec::new();
loop {
if from == 0 || buffer.len() as u64 >= MAX_BYTES {
break;
}
let step = CHUNK.min(from);
from -= step;
let mut chunk = vec![0u8; step as usize];
file.seek(SeekFrom::Start(from)).context("seek the log")?;
file.read_exact(&mut chunk).context("read the log")?;
chunk.extend_from_slice(&buffer);
buffer = chunk;
// One more than asked for, so the first line in the buffer can be
// dropped as the partial one this chunk started in the middle of.
if lines > 0 && buffer.iter().filter(|byte| **byte == b'\n').count() > lines {
break;
}
}
// Lossy on purpose: a log is whatever the process wrote, and a stray
// non-UTF-8 byte must not turn the whole thing into an error at the
// moment somebody is trying to read why it died.
let text = String::from_utf8_lossy(&buffer).into_owned();
let mut kept: Vec<&str> = text.lines().collect();
let mut truncated = from > 0;
if truncated && !kept.is_empty() {
// The first line is whatever the chunk boundary bisected.
kept.remove(0);
}
if lines > 0 && kept.len() > lines {
kept.drain(..kept.len() - lines);
truncated = true;
}
Ok(Tail {
text: kept.join("\n"),
truncated,
})
}
/// Where this server keeps the output of builds it runs.
///
/// Generated data, so `$XDG_DATA_HOME` rather than beside the config --
/// the same split the service scripts follow for their own logs, so both
/// kinds end up somewhere a person expects to find a log.
fn build_log_dir() -> PathBuf {
data_dir().join("builds")
}
/// Everything this server generates for itself, under `$XDG_DATA_HOME`.
///
/// One answer to "where does generated state go", so the build logs and
/// the built-in service script cannot end up under two different
/// interpretations of the same rule -- and the rule itself is the shared
/// one, so this server and ai-app resolve it identically.
pub fn data_dir() -> PathBuf {
wg_app_link::xdg::data_home(crate::PRODUCT)
}
/// The log files for one component's builds, newest first.
///
/// The same shape a service script reports, so the route that serves them
/// does not care which kind it was handed.
/// Which of a component's two logs is wanted.
///
/// Two kinds rather than one list, because they answer different
/// questions: the build log is what this server captured while building
/// the component, and the runtime log is what the component itself wrote
/// while running. Flattening them into a single sequence -- which is how
/// this was first written -- meant the generation index had to carry both
/// "which kind" and "how far back", and since build logs came first the
/// runtime ones sat at an index nothing ever asked for. They were
/// unreachable from the phone, and nothing said so.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogKind {
/// Written by this server while building the component.
Build,
/// Written by the component itself while running, reported by its
/// service script. Never present for an APK, which does not run here.
Runtime,
}
pub fn build_logs(key: &str, component: &str) -> Vec<PathBuf> {
let current = build_log_path(key, component);
let previous = previous_of(&current);
[current, previous]
.into_iter()
.filter(|path| path.is_file())
.collect()
}
fn build_log_path(key: &str, component: &str) -> PathBuf {
// Both are already route-safe identifiers, but a component name comes
// from a project's own file, so anything that could climb out of the
// directory is flattened rather than trusted.
let safe = |text: &str| -> String {
text.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
};
build_log_dir().join(format!("{}-{}.log", safe(key), safe(component)))
}
fn previous_of(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(".1");
PathBuf::from(name)
}
/// Opens this component's build log for a run that is starting, rotating
/// the previous one aside.
///
/// One generation, rotated at the start of a run, for the same reason a
/// service script rotates on start: the split lands where a reader wants
/// it -- this build and the one before -- which after a failure and a
/// retry is the pair worth having.
///
/// A failure to open is not a reason to fail the build. The build is the
/// point; the log is how you read about it afterwards, and losing it
/// leaves the tail on the card exactly as before.
pub fn open_build_log(key: &str, component: &str) -> Option<std::fs::File> {
let path = build_log_path(key, component);
if let Some(parent) = path.parent()
&& let Err(err) = wg_app_link::private::create_dir(parent)
{
tracing::warn!("no build log for {key}'s {component}: {err:#}");
return None;
}
if path.is_file() {
let _ = std::fs::rename(&path, previous_of(&path));
}
match wg_app_link::private::create_file(&path) {
Ok(file) => Some(file),
Err(err) => {
tracing::warn!("no build log for {key}'s {component}: {err:#}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, contents).expect("write");
path
}
#[test]
fn a_short_log_comes_back_whole() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write(dir.path(), "a.log", "one\ntwo\nthree\n");
let tail = tail(&path, 100).expect("tail");
assert_eq!(tail.text, "one\ntwo\nthree");
assert!(!tail.truncated, "nothing was cut off");
}
#[test]
fn asking_for_fewer_lines_takes_them_from_the_end() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write(dir.path(), "a.log", "one\ntwo\nthree\nfour\n");
let tail = tail(&path, 2).expect("tail");
assert_eq!(tail.text, "three\nfour", "the end is the interesting part");
assert!(tail.truncated, "and the reader is told there was more");
}
/// The case the backwards read exists for: more than one chunk, so the
/// boundary lands in the middle of a line and the partial one has to
/// be dropped rather than shown as though it were a whole line.
#[test]
fn a_log_larger_than_one_chunk_reads_from_the_end_without_a_partial_line() {
let dir = tempfile::tempdir().expect("tempdir");
let body: String = (0..40_000).map(|n| format!("line {n}\n")).collect();
let path = write(dir.path(), "big.log", &body);
assert!(
std::fs::metadata(&path).expect("stat").len() > CHUNK * 2,
"spans chunks"
);
let tail = tail(&path, 3).expect("tail");
assert_eq!(tail.text, "line 39997\nline 39998\nline 39999");
assert!(tail.truncated);
}
#[test]
fn zero_means_everything_it_is_allowed_to_read() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write(dir.path(), "a.log", "one\ntwo\n");
let tail = tail(&path, 0).expect("tail");
assert_eq!(tail.text, "one\ntwo");
assert!(!tail.truncated);
}
}
+460
View File
@@ -0,0 +1,460 @@
//! Serves locally-built debug APKs so a phone -- or the `updater` app
//! itself -- can install them, without a browser and without leaving files
//! in the Downloads folder. See the repo README for the whole picture; this
//! is the entry point and the two listeners it runs.
//!
//! Which apps get served is not compiled in: an app is a *project path*
//! added at runtime from the phone (`POST /apps`), and the APK underneath
//! it is rediscovered per request. See `discover` for how that stays fast
//! enough to sit behind an interactive screen, and `config` for what
//! persists.
//!
//! Runs up to two listeners:
//!
//! - `--port` (default 8090), always on, TLS pinned against the CA
//! `certs` generates. The whole API surface the `updater` app itself
//! ever talks to -- see `routes` for the table. Pinned because
//! everything on it either is, or decides, what gets handed to
//! `REQUEST_INSTALL_PACKAGES` next.
//! - `--download` (off by default; pass alone for port 8091, or
//! with a value for another port), plain HTTP, serving only the
//! updater app's own APK at a bare `/` -- for a human's browser,
//! bootstrapping `updater` onto a fresh phone. Plain HTTP because a
//! stock browser has nothing to pin against before that first install.
//!
//! Both bind the WireGuard interface and nothing else (see `wg_address`),
//! so neither is reachable from the LAN. That is the outer of two gates:
//! the tunnel decides who can try, and the bearer token every TLS request
//! carries (see `auth`) decides who is answered. It also means the
//! bootstrap port's plain HTTP travels inside the tunnel's encryption.
mod apkinfo;
mod auth;
mod build_state;
mod checks;
mod config;
mod discover;
mod git;
mod logs;
mod purge;
mod registry;
mod resources;
mod restart;
mod routes;
mod sdk;
mod service;
mod shipped;
mod strip;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use anyhow::{Context, Result};
use clap::Parser;
use config::TokenEntry;
use registry::AppState;
const DEFAULT_PORT: u16 = 8090;
/// A `&str` rather than a `u16` because clap's `default_missing_value` takes
/// one, and this is only otherwise printed -- so the flag's default and the
/// port named in the "not passed" log line below can't drift apart.
const DEFAULT_BOOTSTRAP_PORT: &str = "8091";
/// `$XDG_CONFIG_HOME/dev-updater`, or `~/.config/dev-updater`. Holds
/// `config.ron` and `certs/`.
///
/// Per machine, deliberately, and not in the repo. This repo is shared
/// between a machine and a VM over virtiofs at *different* absolute paths,
/// so one shared `config.ron` means project paths that resolve on only
/// one side -- which showed up as every app reading "not built" on the
/// other. It also keeps the CA's private key off a mount the VM can read,
/// which is what stops a compromised VM from signing a certificate the
/// pinned app would accept.
/// What this binary is called, where a shared function needs to name it.
///
/// The link crate is generic over the two servers using it, so anything it
/// says to a person -- a failure telling somebody to bring the tunnel up,
/// the directory its state lives in -- takes the name from here rather
/// than guessing. One constant so the two cannot disagree.
const PRODUCT: &str = "dev-updater";
/// This server's *own* project: the checkout whose APK it serves as the
/// built-in self entry, and which that card pulls. Config and certificates
/// live outside the repo; see `wg_app_link::xdg`.
///
/// The working directory itself, i.e. this server expects to be run from
/// the root of its own checkout. That is what the service unit sets, and
/// it is where the rest of this project is driven from anyway
/// (`./run-tests.sh`, `./app/build-apk.sh`, `./server/target/*/dev-updater`).
/// The components say where they live from there, exactly as any other
/// project's do -- there is nothing special about this one's layout.
///
/// It used to be `CARGO_MANIFEST_DIR`, which is fixed when the binary is
/// *compiled*: a re-clone, a moved repo, or a binary built in one checkout
/// and run against another left it naming a directory nobody pulls, and the
/// card then quietly lost its branch line and its Pull button while
/// otherwise working. Asking where the process was started is a question
/// with one answer that is true right now, rather than one baked in months
/// ago -- and being wrong about it is loud (see the warning at startup)
/// rather than silent.
fn self_project() -> PathBuf {
let project = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
// Canonical because this path is *compared* -- `add_app` refuses a
// project already in the list, and `/suggestions` marks one as added,
// both by path equality against exactly this value. Those go through
// `canonicalize`, so a symlink anywhere above the checkout would make
// the same directory look like two and let this server's own project
// be added a second time.
//
// Left as-is when it does not resolve: there is nothing to compare
// against then, and the warning below wants to name the path that was
// actually looked for.
project.canonicalize().unwrap_or(project)
}
/// Serves locally-built debug APKs to phones and the updater app.
#[derive(Parser)]
struct Args {
/// TLS port for the manifest, app payloads, and the management routes.
#[arg(long, default_value_t = DEFAULT_PORT)]
port: u16,
/// Address to bind instead of the wg0 interface's. `0.0.0.0` restores
/// the old LAN-wide behaviour, putting the management routes in reach
/// of anything on the network -- still behind the bearer token, but
/// with nothing in front of it. For recovering when the tunnel is
/// down, not for everyday use.
#[arg(long)]
bind: Option<IpAddr>,
/// Invalidate every enrolled token, generate a fresh one, and print
/// its enrollment QR -- the whole lost-phone story.
#[arg(long)]
rotate_token: bool,
/// Also serve the updater app's own APK over plain HTTP at "/", for a
/// bare browser link. Optionally takes a port (default 8091).
#[arg(short, long, num_args = 0..=1, default_missing_value = DEFAULT_BOOTSTRAP_PORT)]
download: Option<u16>,
/// Drive this server's own service, and exit: `install`, `uninstall`,
/// `start`, `stop`, `restart`, `status` or `logs`.
///
/// The same command the running server would use for its own `Server`
/// component, so a script bootstrapping this machine does not have to
/// know how a service is named or invoked -- it asks the binary that
/// decides both. `start.sh` is the caller.
#[arg(long, value_name = "SUBCOMMAND")]
service: Option<String>,
/// Where the added-apps list and repo roots live. Defaults to
/// `$XDG_CONFIG_HOME/dev-updater/config.ron`.
#[arg(long)]
config: Option<PathBuf>,
/// Directory holding the TLS certificates, generated here on first
/// start. Defaults to `$XDG_CONFIG_HOME/dev-updater/certs`.
#[arg(long)]
certs: Option<PathBuf>,
}
/// Runs one service subcommand against this server's own `Server`
/// component and exits with its result.
///
/// Exists so that nothing outside this binary has to know the name of this
/// service or how its script is invoked. Both are derived here, by the
/// same `service::driver` the running server uses, from the same
/// declaration -- so a bootstrap script cannot drift out of step with them
/// the way a second copy of the arguments would.
fn drive_own_service(self_project: &Path, subcommand: &str) -> Result<()> {
let declaration = config::project_config(self_project);
let component = declaration
.components
.iter()
.find(|component| service::driver(registry::SELF_KEY, component).is_some())
.with_context(|| {
format!(
"{} declares no server component with a service, so there is nothing to \
{subcommand}",
config::PROJECT_CONFIG_FILE
)
})?;
let script = service::driver(registry::SELF_KEY, component).expect("just found one");
// Written out first: `install` is the step that needs it, and it is
// also the step a fresh machine runs before any server has started.
shipped::install().context("write this server's shipped scripts")?;
let output = service::run(&script, self_project, component.cwd(), subcommand)
.map_err(|message| anyhow::anyhow!("{subcommand} failed: {message}"))?;
if !output.is_empty() {
println!("{output}");
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt().with_env_filter("info").init();
// Before anything else can rebuild this binary: once a build replaces
// the file, the running process can no longer name where it came from.
restart::remember_executable();
// Before anything can be asked about a service: a component declaring
// `Managed` is driven through this file, and a stale copy of it would
// be the shared default drifting in the one way it was meant to stop.
if let Err(err) = shipped::install() {
tracing::warn!(
"could not write this server's shipped scripts to {} ({err}) -- a component \
declaring Managed, and a build asking for a progress bar, will not work until \
this succeeds",
crate::logs::data_dir().display()
);
}
let args = Args::parse();
// Printed once the bind address is known, so the QR carries the
// address the phone should actually dial.
let mut pending_enrollment: Option<String> = None;
let config_path = args
.config
.unwrap_or_else(|| wg_app_link::xdg::config_home(PRODUCT).join("config.ron"));
// The updater app's own Gradle project, not the repo root: it is the
// path a suggestion for this repo would carry, and matching it is what
// stops the built-in self entry from also being offered as something to
// add.
let self_project = self_project();
// Said out loud because the alternative is finding out from a card that
// looks fine and never updates: with no checkout there is no branch,
// no Pull and no commit count, which reads exactly like a project that
// simply has no news.
if git::status(&self_project).is_none() {
tracing::warn!(
"no checkout at {}, so the {} card can't show commits or offer Pull. This \
server takes its own project from the working directory: start it from the \
root of the checkout you pull, as the service unit does.",
self_project.display(),
registry::SELF_LABEL,
);
} else if !self_project.join(config::PROJECT_CONFIG_FILE).is_file() {
// In a checkout, but not one of *this* project: same broken card,
// different cause, so it gets its own sentence rather than being
// folded into the one above.
tracing::warn!(
"{} has no {}, so the {} card has nothing to build. This server takes its own \
project from the working directory: start it from the root of the checkout \
you pull.",
self_project.display(),
config::PROJECT_CONFIG_FILE,
registry::SELF_LABEL,
);
}
// Before the state, the certificates and the listeners: this mode
// installs or reports on the service and exits, and doing any of that
// first would mean generating a CA in order to run `status`.
if let Some(subcommand) = args.service.as_deref() {
return drive_own_service(&self_project, subcommand);
}
let state = Arc::new(
AppState::new(config_path.clone(), self_project)
.with_context(|| format!("failed to load {}", config_path.display()))?,
);
// Asked on this server's own behalf, before any phone asks. What a
// component is doing is known only from the first answer of *this
// process's* life, and a row whose state is unknown is drawn with no
// buttons at all -- so a restart is otherwise visible on the phone as
// every server row losing its controls until a check it started
// catches up. Restarting is the ordinary way this server is updated,
// which makes that the moment it is most likely to be looked at.
//
// Free, near enough: it is the work the first `/manifest` would start
// anyway, off the request path either way, and it returns at once.
routes::refresh_checkouts(&state, &state.entries());
let certs_dir = args
.certs
.unwrap_or_else(|| wg_app_link::xdg::config_home(PRODUCT).join("certs"));
let certificates =
wg_app_link::certs::ensure(PRODUCT, &certs_dir, &wg_app_link::netif::local_addresses())
.with_context(|| {
format!("failed to prepare certificates in {}", certs_dir.display())
})?;
if certificates.ca_is_new {
tracing::warn!(
"a new CA was generated in {} -- any installed updater app pins the previous one \
and can no longer reach this server. Rebuild it (app/build-apk.sh, which embeds \
this CA) and reinstall over the bootstrap port: --download",
certs_dir.display(),
);
}
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
&certificates.leaf_cert,
&certificates.leaf_key,
)
.await
.context("failed to load TLS cert/key")?;
// Token bootstrap: first run generates one; --rotate-token replaces
// whatever exists. Either way the plaintext appears exactly once, in
// the QR printed here.
if args.rotate_token || state.tokens().is_empty() {
let rotating = args.rotate_token && !state.tokens().is_empty();
let token = auth::generate_token();
state.set_tokens(vec![TokenEntry {
name: "phone".to_string(),
sha256: auth::token_hash_hex(&token),
}])?;
if rotating {
tracing::info!("rotated the enrolled token; the previous one is now invalid");
}
pending_enrollment = Some(token);
}
let bind_ip = match args.bind {
Some(ip) => {
tracing::warn!(
"binding {ip} by explicit --bind override -- anything that can reach this \
address can now reach the management routes, with only the bearer token in \
front of them"
);
ip
}
None => wg_app_link::netif::wg_address(PRODUCT)?,
};
if let Some(bootstrap_port) = args.download {
// The bootstrap listener serves exactly one thing: this app's own
// APK. Saying so up front, with the address to open and whether
// there is anything behind it, because the alternative is finding
// out from a bare 404 in a phone browser -- which is where this
// flag is used and where there is least to go on.
let self_entry = state.entry(registry::SELF_KEY);
match self_entry
.as_ref()
.and_then(|entry| entry.resolve_apk(None))
{
// The age is here because this listener serves the file already
// on disk and builds nothing, so an old APK installs in silence.
// That one is unusually expensive to land on: a fresh install is
// not enrolled, and everything that would replace it -- Update,
// and the QR scan that gets a device enrolled at all -- lives in
// the copy being installed. Ship a stale one and the way out of
// it is another trip through this flag.
Some(apk) => tracing::info!(
"bootstrap link: open http://{bind_ip}:{bootstrap_port} in the phone's browser \
to install {} ({} build from {}). Run ./app/build-apk.sh first if that is \
older than the checkout.",
self_entry
.map(|entry| entry.label.clone())
.unwrap_or_default(),
apk.variant,
describe_age(apk.modified),
),
None => tracing::warn!(
"--download is on, but there is no APK to serve: nothing is built under \
{}. http://{bind_ip}:{bootstrap_port} will answer 404 until you build it -- \
run ./app/build-apk.sh on this machine.",
self_entry
.map(|entry| entry.project_path.display().to_string())
.unwrap_or_default(),
),
}
tokio::spawn(run_bootstrap_listener(
Arc::clone(&state),
bind_ip,
bootstrap_port,
));
} else {
tracing::info!(
"--download not passed -- port {DEFAULT_BOOTSTRAP_PORT} (updater bootstrap link) won't be served",
);
}
tracing::info!("config: {}", config_path.display());
let roots = state.repo_roots();
if roots.is_empty() {
tracing::info!(
"no repo roots configured yet -- set one from the app's Add screen to get \
project suggestions",
);
} else {
for root in &roots {
tracing::info!("scanning for projects under {}", root.display());
}
}
for entry in state.entries() {
match entry.resolve_apk(None) {
Some(apk) => tracing::info!(" {} -> {}", entry.key, apk.path.display()),
None => tracing::warn!(
" {} -> no build found under {} (it will show as not built)",
entry.key,
entry.project_path.display(),
),
}
}
if let Some(token) = pending_enrollment {
auth::print_enrollment(bind_ip, args.port, &token)?;
}
let addr = SocketAddr::new(bind_ip, args.port);
tracing::info!("serving https://{addr}");
let app = routes::tls_router(Arc::clone(&state)).layer(axum::middleware::from_fn_with_state(
state,
auth::require_token,
));
axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.context("TLS listener failed")?;
Ok(())
}
/// How long ago `when` was, in the largest whole unit that fits.
///
/// Rounded down and never more precise than the unit it names, because the
/// only question being answered is "is this the build I just made, or one
/// from last week?".
fn describe_age(when: SystemTime) -> String {
let Ok(elapsed) = when.elapsed() else {
// An mtime in the future is a clock that moved, not an age.
return "a timestamp in the future".to_string();
};
let seconds = elapsed.as_secs();
let (count, unit) = match seconds {
..60 => return "under a minute ago".to_string(),
60..3600 => (seconds / 60, "minute"),
3600..86400 => (seconds / 3600, "hour"),
_ => (seconds / 86400, "day"),
};
let plural = if count == 1 { "" } else { "s" };
format!("{count} {unit}{plural} ago")
}
async fn run_bootstrap_listener(state: Arc<AppState>, bind_ip: IpAddr, port: u16) {
// Same address as the TLS listener: inside the tunnel, so this port's
// plain HTTP is carried encrypted anyway, and a phone that can reach
// the server can reach its bootstrap link too.
let addr = SocketAddr::new(bind_ip, port);
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => listener,
Err(err) => {
tracing::error!("failed to bind bootstrap listener on port {port}: {err:#}");
return;
}
};
tracing::info!("serving http://{addr} (updater bootstrap link)");
if let Err(err) = axum::serve(
listener,
routes::bootstrap_router(state).into_make_service(),
)
.await
{
tracing::error!("bootstrap listener failed: {err:#}");
}
}
+315
View File
@@ -0,0 +1,315 @@
//! What a component leaves on the build machine, and taking it away when
//! the service goes.
//!
//! Uninstalling used to remove the service and stop there, which is the
//! right default -- the built files stay, and so does whatever the thing
//! accumulated while it ran. But there is no other way to reach any of it
//! from a phone, so "uninstall and then clean up by hand at the machine"
//! is a capability withheld rather than a decision offered. The Uninstall
//! dialog therefore asks about three things separately, and this module is
//! what it asks.
//!
//! The three are deliberately not one switch. They differ in what losing
//! them costs: logs are a record of what already happened, data is what
//! the thing produced, and config is what somebody typed. Only the first
//! is on by default.
//!
//! **Nothing here is recoverable, and nothing here is guarded by path.**
//! A `data:` or `config:` the project declared is removed wherever it
//! points -- Iris's call, on 2026-08-29, over the alternative of refusing
//! anything outside the XDG directories. What stands in for that guard is
//! the dialog: the resolved path is shown, per toggle, before the button
//! can be pressed, so the phone never asks for a path nobody saw. Any
//! change that stops the path being displayed removes the only check
//! there is.
use std::path::{Path, PathBuf};
use crate::config::ResourceFacts;
/// Where a project keeps the state that outlives a build.
///
/// `None` is "this project has not said", which is a different thing from
/// a path that is there and empty, and the dialog draws it differently:
/// one greys the toggle out saying nothing is there, the other says it
/// cannot tell. A path that *is* known is resolved whether or not it
/// exists, because absent from the display is how "we did not look" and
/// "there is nothing" become the same thing.
#[derive(Default)]
pub struct StatePaths {
pub data: Option<PathBuf>,
pub config: Option<PathBuf>,
}
/// Turns what a project said about itself into the two directories.
///
/// An explicit `data:`/`config:` wins. Otherwise they follow from the
/// project's `name:` -- `$XDG_DATA_HOME/<name>` and
/// `$XDG_CONFIG_HOME/<name>`, through the same `wg_app_link::xdg` both
/// servers use for their own state rather than a second reading of the
/// same rule. A project that says neither gets neither, and nothing is
/// invented from the checkout's directory name, the config key, or a
/// crate name: those are all facts that happen to be true rather than
/// things the project asserted, and a guess that lands on a directory
/// which does not exist reads exactly like a directory that is empty.
///
/// A path may start with `~`, and a relative one is resolved against the
/// project -- the rule `cwd` already follows, so a project's file has one
/// meaning of "relative" rather than two.
pub fn paths(facts: &ResourceFacts, project: &Path) -> StatePaths {
StatePaths {
data: resolve(facts.data.as_deref(), project)
.or_else(|| facts.name.as_deref().map(wg_app_link::xdg::data_home)),
config: resolve(facts.config.as_deref(), project)
.or_else(|| facts.name.as_deref().map(wg_app_link::xdg::config_home)),
}
}
fn resolve(declared: Option<&Path>, project: &Path) -> Option<PathBuf> {
let declared = declared?;
// The same expansion a path typed on a phone gets, so `~` means one
// thing across the whole server rather than one thing per reader.
let expanded = declared
.to_str()
.map(crate::config::expand_tilde)
.unwrap_or_else(|| declared.to_path_buf());
Some(if expanded.is_absolute() {
expanded
} else {
project.join(expanded)
})
}
/// Which of the three the phone asked to take away.
///
/// A struct rather than three arguments so a caller cannot transpose two
/// booleans, which is the one mistake here that deletes the wrong thing
/// without failing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Wanted {
pub logs: bool,
pub data: bool,
pub config: bool,
}
impl Wanted {
/// Nothing beyond removing the service itself.
pub fn nothing(&self) -> bool {
!self.logs && !self.data && !self.config
}
/// Removing the data implies removing the logs.
///
/// Applied here rather than trusted from the request, because the
/// dialog forces the same pairing and the two must not be able to
/// disagree -- a project whose service script writes its log inside
/// its own data directory would otherwise have the log deleted by a
/// request that said to keep it, and be told the opposite.
pub fn normalized(self) -> Self {
Self {
logs: self.logs || self.data,
..self
}
}
}
/// Removes what was asked for, and says what could not be removed.
///
/// Returns one line per failure, in the order the three are listed in the
/// dialog. An empty result is a clean sweep. Failures do not stop the
/// rest: the service is already gone by the time this runs, so stopping
/// half way would leave a component that is uninstalled, partly cleaned,
/// and reported as failed -- three states to reason about instead of one.
///
/// `log_files` is collected by the caller *before* the service is
/// uninstalled, because the script that reports where its log lives is
/// the thing being removed.
pub fn remove(paths: &StatePaths, log_files: &[PathBuf], wanted: Wanted) -> Vec<String> {
let wanted = wanted.normalized();
let mut problems = Vec::new();
if wanted.logs {
for file in log_files {
if let Err(err) = remove_file(file) {
problems.push(format!(
"could not remove the log {}: {err}",
file.display()
));
}
}
// The directories those files were the only contents of. Removed
// only when empty, so this can never take away more than the logs
// it was pointed at -- and a component's log directory is one this
// server made, so leaving it behind is leaving the same kind of
// orphan the logs themselves were.
for parent in log_files.iter().filter_map(|file| file.parent()) {
let _ = std::fs::remove_dir(parent);
}
}
// Nothing to remove when the project never said where it is. The
// dialog does not offer the toggle in that state, so this is the
// belt to its braces rather than a case anyone reaches by hand.
for (asked, what, path) in [
(wanted.data, "data", paths.data.as_deref()),
(wanted.config, "config", paths.config.as_deref()),
] {
let Some(path) = path.filter(|_| asked) else {
continue;
};
if let Err(err) = remove_tree(path) {
problems.push(format!(
"could not remove the {what} at {}: {err}",
path.display()
));
}
}
problems
}
/// Already gone is the outcome that was asked for, not a failure --
/// otherwise pressing Uninstall twice reports a problem the second time.
fn remove_file(path: &Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
fn remove_tree(path: &Path) -> std::io::Result<()> {
match std::fs::remove_dir_all(path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn facts(name: Option<&str>, data: Option<&str>) -> ResourceFacts {
ResourceFacts {
name: name.map(str::to_string),
data: data.map(PathBuf::from),
config: None,
}
}
/// A project that names itself gets both directories from that name,
/// resolved the way both servers resolve their own.
#[test]
fn a_name_is_enough_to_place_both_directories() {
let paths = paths(&facts(Some("ai-app"), None), Path::new("/repos/ai-app"));
assert!(paths.data.as_ref().unwrap().ends_with("ai-app"));
assert!(paths.config.as_ref().unwrap().ends_with("ai-app"));
assert_ne!(paths.data, paths.config);
}
/// The correction that produced this module: nothing is invented from
/// the checkout's directory, the config key, or anything else that
/// merely happens to be true. Not said is not knowing.
#[test]
fn a_project_that_says_nothing_places_nothing() {
let paths = paths(&ResourceFacts::default(), Path::new("/repos/ai-app"));
assert_eq!(paths.data, None);
assert_eq!(paths.config, None);
}
#[test]
fn an_explicit_path_wins_over_the_name() {
let paths = paths(
&facts(Some("ai-app"), Some("/srv/sessions")),
Path::new("/repos/ai-app"),
);
assert_eq!(paths.data.as_deref(), Some(Path::new("/srv/sessions")));
assert!(paths.config.as_ref().unwrap().ends_with("ai-app"));
}
#[test]
fn a_relative_path_is_read_against_the_project() {
let paths = paths(&facts(None, Some("state")), Path::new("/repos/ai-app"));
assert_eq!(
paths.data.as_deref(),
Some(Path::new("/repos/ai-app/state"))
);
}
/// The pairing the dialog also enforces. Stated in both places on
/// purpose, and asserted here because this is the half that deletes.
#[test]
fn asking_for_the_data_asks_for_the_logs_too() {
let asked = Wanted {
logs: false,
data: true,
config: false,
};
assert!(asked.normalized().logs);
// And not the other way: logs are the cheap one.
let logs_only = Wanted {
logs: true,
..Default::default()
};
assert!(!logs_only.normalized().data);
}
#[test]
fn removing_what_is_already_gone_is_not_a_failure() {
let dir = std::env::temp_dir().join(format!("purge-test-{}", std::process::id()));
let paths = StatePaths {
data: Some(dir.join("data")),
config: Some(dir.join("config")),
};
let problems = remove(
&paths,
&[dir.join("nothing.log")],
Wanted {
logs: true,
data: true,
config: true,
},
);
assert!(problems.is_empty(), "{problems:?}");
}
/// Asked to remove something the project never located, there is
/// nothing to remove and nothing to complain about.
#[test]
fn a_path_that_was_never_known_removes_nothing() {
let problems = remove(
&StatePaths::default(),
&[],
Wanted {
logs: false,
data: true,
config: true,
},
);
assert!(problems.is_empty(), "{problems:?}");
}
#[test]
fn what_was_asked_for_goes_and_the_rest_stays() {
let dir = std::env::temp_dir().join(format!("purge-keep-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let paths = StatePaths {
data: Some(dir.join("data")),
config: Some(dir.join("config")),
};
std::fs::create_dir_all(paths.data.as_ref().unwrap()).unwrap();
std::fs::create_dir_all(paths.config.as_ref().unwrap()).unwrap();
std::fs::write(paths.data.as_ref().unwrap().join("session"), "x").unwrap();
let problems = remove(
&paths,
&[],
Wanted {
logs: false,
data: true,
config: false,
},
);
assert!(problems.is_empty(), "{problems:?}");
assert!(!paths.data.as_ref().unwrap().exists(), "asked for");
assert!(paths.config.as_ref().unwrap().exists(), "not asked for");
let _ = std::fs::remove_dir_all(&dir);
}
}
File diff suppressed because it is too large. Load diff
+266
View File
@@ -0,0 +1,266 @@
//! Reading what a project says about itself.
//!
//! A project's `resources:` declaration points at values the project
//! keeps for its own use -- its name, where its data and config live --
//! and this reads the few of them this server needs. The file belongs to
//! the project: its own code is expected to read the same one, which is
//! why unrecognised keys are ignored rather than refused.
//!
//! **Off the request path, like every other slow answer here.** The
//! `Script` variant spawns a process, and `/manifest` is fetched on every
//! open, resume and Refresh -- the one thing that path must not do. So
//! this goes through [`crate::checks`] beside the git and service checks,
//! answers arriving after the response that started them, and the phone
//! waiting on all three through the same outstanding count.
//!
//! Not knowing is a first-class answer. A project that declares nothing,
//! a file that will not parse, a script that fails: each leaves the
//! Uninstall dialog saying it cannot tell where the data lives, rather
//! than filling in a directory that looks plausible. The version of this
//! that guessed -- first from the config key, then from the checkout's
//! directory name -- was wrong in a way nothing could see, because a path
//! that is not there reads as "this component keeps nothing here".
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::checks::{Checks, Report};
use crate::config::{ResourceFacts, Resources};
/// How long a resources script is given before it is treated as having
/// failed.
///
/// It prints three values; anything that takes longer than this is stuck
/// rather than slow, and a check that never finishes holds the key
/// claimed forever -- so the card would sit on "still finding out" with
/// nothing ever arriving.
const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Reads a project's resources, or says why it could not.
///
/// The error is what the card shows, so it names the file or the command
/// rather than only the underlying complaint -- "no such file" without a
/// path is not something a person can act on.
pub fn read(project: &Path, declaration: &Resources) -> Result<ResourceFacts, String> {
match declaration {
Resources::Inline(facts) => Ok(facts.clone()),
Resources::Ron(path) => {
// `~` expanded the same way it is for a path typed on a
// phone; `join` on an absolute path yields that path, so a
// resources file may live outside the checkout.
let path = project.join(
path.to_str()
.map(crate::config::expand_tilde)
.unwrap_or_else(|| path.clone()),
);
let text = std::fs::read_to_string(&path)
.map_err(|err| format!("reading {}: {err}", path.display()))?;
parse(&text).map_err(|err| format!("in {}: {err}", path.display()))
}
Resources::Script(command) => {
let text = run(project, command)?;
parse(&text).map_err(|err| format!("in the output of {}: {err}", command.to_line()))
}
}
}
/// The project's own house rules, which are this server's: the file is the
/// *body* of the struct, and an optional value is written bare.
///
/// Shared with `wg_app_link::format` rather than reimplemented, because
/// the project's own code parses the same file and the two must agree
/// about whether it has outer parentheses.
fn parse(text: &str) -> Result<ResourceFacts, String> {
wg_app_link::format::parse(text).map_err(|err| err.to_string())
}
fn run(project: &Path, command: &crate::config::Command) -> Result<String, String> {
use std::io::Read;
let mut child = command
.to_process(project, None, &[])?
// Closed, because a script that asks a question would otherwise
// wait for an answer nobody is there to give -- the same rule the
// service scripts run under.
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|err| format!("starting {}: {err}", command.to_line()))?;
let deadline = std::time::Instant::now() + SCRIPT_TIMEOUT;
loop {
match child.try_wait() {
Err(err) => return Err(format!("waiting on {}: {err}", command.to_line())),
Ok(Some(status)) => {
let mut out = String::new();
if let Some(mut pipe) = child.stdout.take() {
let _ = pipe.read_to_string(&mut out);
}
if status.success() {
return Ok(out);
}
let mut err = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut err);
}
return Err(format!(
"{} failed ({status}): {}",
command.to_line(),
err.lines().next().unwrap_or("no output").trim()
));
}
Ok(None) if std::time::Instant::now() >= deadline => {
let _ = child.kill();
return Err(format!(
"{} did not answer within {}s",
command.to_line(),
SCRIPT_TIMEOUT.as_secs()
));
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
}
}
}
/// One project to read, and where from.
pub struct Target {
pub key: String,
pub project: PathBuf,
pub declaration: Resources,
}
/// The resources this server has read, by project key.
///
/// A facade over [`Checks`] in the same shape as `git::RemoteChecks` and
/// `service::ServiceChecks`, so all three are started, counted and
/// reported the same way.
#[derive(Default, Clone)]
pub struct ResourceChecks(Arc<Checks<String, ResourceFacts>>);
impl ResourceChecks {
/// What this project last said about itself. `None` before anything
/// has been read, which is not the same as a project that declares
/// nothing -- the caller knows which by whether there is a
/// declaration at all.
pub fn facts(&self, key: &str) -> Option<ResourceFacts> {
self.0.answer(key)
}
pub fn is_checking(&self, key: &str) -> bool {
self.0.is_checking(key)
}
pub fn error(&self, key: &str) -> Option<String> {
self.0.error(key)
}
/// Reads every target that isn't already being read. Returns at once.
pub fn refresh(&self, targets: Vec<Target>) {
for target in targets {
let Target {
key,
project,
declaration,
} = target;
self.0
.start(key, move |_| Report::from(read(&project, &declaration)));
}
}
/// Forgets a project being removed, so its key cannot be answered
/// after nothing points at it.
pub fn forget(&self, key: &str) {
self.0.retain(|held| held != key);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_is_read_as_the_body_of_the_struct() {
let dir = std::env::temp_dir().join(format!("resources-ron-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("resources.ron"),
"// what this project calls itself\nname: \"ai-app\",\n",
)
.unwrap();
let facts = read(&dir, &Resources::Ron(PathBuf::from("resources.ron"))).unwrap();
assert_eq!(facts.name.as_deref(), Some("ai-app"));
assert_eq!(facts.data, None, "not said is not a value to invent");
let _ = std::fs::remove_dir_all(&dir);
}
/// The project's file, so what this server does not recognise is
/// somebody else's business rather than an error.
#[test]
fn keys_this_server_does_not_know_are_ignored() {
let dir = std::env::temp_dir().join(format!("resources-extra-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("r.ron"),
"name: \"ai-app\",\nmodelCache: \"~/models\",\nport: 8443,\n",
)
.unwrap();
let facts = read(&dir, &Resources::Ron(PathBuf::from("r.ron"))).unwrap();
assert_eq!(facts.name.as_deref(), Some("ai-app"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_file_says_which_file() {
let err = read(
Path::new("/nowhere-at-all"),
&Resources::Ron(PathBuf::from("r.ron")),
)
.unwrap_err();
assert!(err.contains("r.ron"), "{err}");
}
#[test]
fn a_script_is_read_from_its_output() {
let dir = std::env::temp_dir().join(format!("resources-script-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("say.sh");
std::fs::write(&script, "#!/bin/sh\necho 'name: \"computed\",'\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let facts = read(
&dir,
&Resources::Script(crate::config::Command::from_line("./say.sh")),
)
.unwrap();
assert_eq!(facts.name.as_deref(), Some("computed"));
let _ = std::fs::remove_dir_all(&dir);
}
/// A failing script is a state the card has a word for, not something
/// to fall back from.
#[test]
fn a_failing_script_reports_rather_than_answering() {
let dir = std::env::temp_dir().join(format!("resources-fail-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("no.sh");
std::fs::write(&script, "#!/bin/sh\necho 'nope' >&2\nexit 3\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let err = read(
&dir,
&Resources::Script(crate::config::Command::from_line("./no.sh")),
)
.unwrap_err();
assert!(err.contains("nope"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+367
View File
@@ -0,0 +1,367 @@
//! Restarting this server in place, after it has rebuilt itself.
//!
//! Only the self-update path uses this: pulling this repository rebuilds
//! the binary, and a new binary sitting on disk while the old process
//! keeps serving is not an update.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant, SystemTime};
use crate::config::Command;
/// APK downloads currently being sent, so a restart can wait for them
/// instead of cutting them off.
///
/// A build of this server's own project ends in an exec, and the thing
/// that most often happens *immediately* after such a build is the phone
/// downloading the APK it just produced. Those two raced: the exec was on
/// a fixed two-second timer, the phone learned the build had finished up
/// to a poll interval late, and an APK takes longer than the remainder to
/// transfer. The download died with the socket and the card reported that
/// the server could not be reached -- in the middle of the update that was
/// working.
///
/// Counting them is the honest fix. A timer longer than "most" downloads
/// would be a guess about a number this can simply know.
#[derive(Default)]
pub struct Downloads(AtomicUsize);
impl Downloads {
/// Counts one download until the returned guard is dropped, which
/// happens when the response body is finished or the client goes away
/// -- either way, when this server is no longer sending.
pub fn start(self: &Arc<Self>) -> DownloadGuard {
self.0.fetch_add(1, Ordering::SeqCst);
DownloadGuard(Arc::clone(self))
}
fn in_flight(&self) -> usize {
self.0.load(Ordering::SeqCst)
}
}
pub struct DownloadGuard(Arc<Downloads>);
impl Drop for DownloadGuard {
fn drop(&mut self) {
self.0.0.fetch_sub(1, Ordering::SeqCst);
}
}
/// How long a restart will wait for downloads to finish before going
/// anyway.
///
/// Bounded because a stalled client must not be able to keep this server
/// on an old binary indefinitely -- at that point the update not landing
/// is the worse failure. Generous enough for a large APK over a phone's
/// connection.
const DOWNLOAD_GRACE: Duration = Duration::from_secs(120);
/// How long to give a service manager to actually stop this process after
/// being asked to, before concluding it will not and restarting without it.
///
/// Generous, because being restarted twice is a second's interruption
/// while restarting *early* would race the manager's own start and could
/// leave two of this server, or none. Bounded, because the phone reaches
/// this machine only through this process: a restart that quietly never
/// happens is the failure that ends with somebody at a keyboard.
const HANDOVER: Duration = Duration::from_secs(30);
/// Where this process was started from, captured before anything can
/// rebuild it.
///
/// A process-wide value rather than something passed down, because it is
/// process identity: there is exactly one answer, it never changes, and
/// the one thing that *would* change it is the rebuild this module exists
/// to survive. Reading it late is precisely the bug -- see below.
static EXECUTABLE: OnceLock<PathBuf> = OnceLock::new();
/// What that file looked like at startup, so a later build can be asked
/// whether it actually replaced it. `None` if it couldn't be read.
static ORIGINAL: OnceLock<Option<Identity>> = OnceLock::new();
/// Enough of a file to tell "untouched" from "something wrote here".
///
/// Deliberately `stat` rather than a hash of the contents. The question is
/// whether the build replaced the binary, and cargo replaces it by
/// renaming a new file over the old -- which changes the inode, and almost
/// always the length and mtime too. Reading tens of megabytes to answer
/// what three numbers already answer would cost more than the restart it
/// is trying to avoid.
#[derive(Debug, PartialEq, Eq)]
struct Identity {
inode: u64,
len: u64,
modified: Option<SystemTime>,
}
fn identity(path: &Path) -> Option<Identity> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).ok()?;
Some(Identity {
inode: meta.ino(),
len: meta.len(),
modified: meta.modified().ok(),
})
}
/// Whether the binary on disk is still the one this process is running.
///
/// A build that finds nothing to do leaves the file alone, and exec-ing
/// into a byte-identical binary drops every open connection to achieve
/// nothing -- so the self-update path asks this before restarting.
///
/// Answers `false` whenever it cannot tell, which is the behaviour there
/// was before this check existed: a restart that wasn't needed is a
/// second's interruption, while a skipped one leaves the old build serving
/// and looks like the update silently failing.
pub fn binary_unchanged() -> bool {
let (Some(exe), Some(Some(original))) = (EXECUTABLE.get(), ORIGINAL.get()) else {
return false;
};
identity(exe).is_some_and(|now| now == *original)
}
/// Records the running binary's path. Call once, at startup.
///
/// This has to happen before a rebuild replaces the file. Cargo installs a
/// new binary by renaming over the old one, which unlinks the inode this
/// process is running from; from that moment Linux reports the path as
/// `/path/to/binary (deleted)`, and `current_exe` hands that string back
/// verbatim. Exec'ing it fails with "no such file or directory" -- the
/// restart failing at the one moment it was needed.
pub fn remember_executable() {
match std::env::current_exe() {
Ok(path) => {
let _ = ORIGINAL.set(identity(&path));
let _ = EXECUTABLE.set(path);
}
Err(err) => tracing::warn!(
"cannot determine this binary's path, so restarting after a \
self-update won't work: {err}"
),
}
}
/// How long to wait before exec-ing, so whatever asked for the restart is
/// answered first.
///
/// Both callers need it for the same reason: a reply that dies with the
/// socket looks like a failure, whether it was a build reporting success
/// or a Restart button asking for exactly this.
const DELAY: Duration = Duration::from_secs(2);
/// Replaces this process with the binary now on disk, shortly.
///
/// `exec` rather than spawn-and-exit so the process keeps its PID:
/// whatever supervises this server sees one continuous process rather than
/// one vanishing and another appearing, so nothing has to be configured to
/// restart it. Where there is no supervisor, the exec *is* the restart.
///
/// Returns at once; the exec happens on its own thread after [`DELAY`],
/// and after any download in flight has finished -- see [`Downloads`].
/// Waits until replacing this process would not interrupt anything: the
/// reply is out, and no APK is still going down the wire.
///
/// Split out so the two ways of restarting -- exec-ing in place, and
/// asking a service manager to do it -- wait the same way rather than one
/// of them being remembered to.
pub fn settle(downloads: &Downloads) {
std::thread::sleep(DELAY);
// The reply is out by now; what may not be out is an APK. Waiting here
// rather than lengthening DELAY because the question is not "how long
// is long enough", which nobody can answer, but "is this server still
// sending", which it knows.
let deadline = Instant::now() + DOWNLOAD_GRACE;
while downloads.in_flight() > 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(200));
}
if downloads.in_flight() > 0 {
tracing::warn!(
"restarting with {} download(s) still open -- they were given {}s",
downloads.in_flight(),
DOWNLOAD_GRACE.as_secs(),
);
}
}
/// Replaces this process with the binary on disk, now.
///
/// Only returns on failure; on success this process is gone. The caller
/// is expected to have called [`settle`] first.
pub fn exec_now() {
use std::os::unix::process::CommandExt;
let Some(exe) = EXECUTABLE.get().cloned() else {
tracing::error!("no remembered binary path, so not restarting");
return;
};
if !exe.is_file() {
tracing::error!(
"{} is gone, so not restarting -- the old binary keeps serving",
exe.display()
);
return;
}
let args: Vec<String> = std::env::args().skip(1).collect();
tracing::info!("restarting into {}", exe.display());
let err = std::process::Command::new(&exe).args(&args).exec();
tracing::error!("restart failed, carrying on with the old binary: {err}");
}
/// What this server needs in order to ask its supervisor to restart it:
/// the same three things any other server component is driven with.
///
/// Carried as owned values because the restart outlives the request that
/// asked for it -- it happens on its own thread, after the reply.
pub struct Handover {
pub script: Command,
pub project: PathBuf,
pub cwd: Option<PathBuf>,
}
/// Restarts this server once nothing is left to interrupt: through its
/// supervisor when one is holding it, and by exec-ing the binary on disk
/// when nothing is.
///
/// Returns at once. Every restart of this server goes through here --
/// the Restart button and the end of a self-build alike -- so that the
/// two cannot come to mean different things. They did once: the button
/// exec'd unconditionally on the grounds that exec keeps the PID, which
/// meant it was the one restart a refreshed unit did not apply to.
///
/// `handover` is `None` only when this project declares no server script
/// at all, which leaves the exec as the only restart there is.
pub fn deferred(handover: Option<Handover>, downloads: Arc<Downloads>) {
std::thread::spawn(move || {
settle(&downloads);
if handover.is_some_and(|handover| through_service(&handover)) {
return;
}
exec_now();
});
}
/// Asks the service manager to restart this server, reporting whether it
/// got that far.
///
/// This is the answer to "can't it just stop itself and start again?". It
/// cannot -- a process that stops itself has nothing left to start it --
/// but its *supervisor* can, and that is the whole difference.
///
/// Going through the manager is also the only way a rewritten unit takes
/// effect: `install` writes the file, and only a manager-driven start
/// reads it. An exec cannot, because it inherits the file descriptors of
/// the process it replaces -- so a newly declared `StandardOutput=` would
/// go on writing wherever the old one did.
///
/// Answers false when no manager is holding this server -- run by hand, or
/// never installed -- because then asking a script to stop it would stop it
/// for good, and exec-ing is the only restart available. That is why the
/// state is *asked* rather than assumed.
fn through_service(handover: &Handover) -> bool {
let Handover {
script,
project,
cwd,
} = handover;
match crate::service::status(script, project, cwd.as_deref()) {
Ok(crate::service::ServiceState::Running) => {}
Ok(state) => {
tracing::info!("this server is {state:?} to its manager, so exec-ing instead");
return false;
}
Err(err) => {
tracing::warn!("could not ask how this server is run ({err}), so exec-ing");
return false;
}
}
tracing::info!("restarting through the service manager, so a refreshed unit applies");
if let Err(message) = crate::service::spawn_detached(script, project, cwd.as_deref(), "restart")
{
// The script could not even be started, which it did without
// stopping us -- so exec-ing is still available, and still better
// than not restarting at all.
tracing::error!("could not ask the service manager to restart this server: {message}");
return false;
}
// Nothing is waited on: what was asked for is this process ending, so
// the answer arrives as a signal rather than as an exit status. If it
// does not arrive, the manager did not do it -- the script failed, or
// it restarted something that is not us -- and this server is still
// the old binary with the update undelivered. Falling back to the exec
// then costs a redundant restart at worst, where trusting the manager
// and waiting forever costs the phone its only way back in.
std::thread::sleep(HANDOVER);
tracing::warn!(
"still running {}s after asking the service manager to restart -- exec-ing instead",
HANDOVER.as_secs()
);
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rewritten_file_is_not_the_file_it_was() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("binary");
std::fs::write(&path, b"one").unwrap();
let before = identity(&path).unwrap();
assert_eq!(
identity(&path).unwrap(),
before,
"reading twice must not look like a change"
);
// How cargo installs a new binary, and the reason inode is part of
// the identity at all: the old file is unlinked, not overwritten.
let replacement = dir.path().join("new");
std::fs::write(&replacement, b"two").unwrap();
std::fs::rename(&replacement, &path).unwrap();
assert_ne!(identity(&path).unwrap(), before);
}
/// The whole predicate, against a real binary that nothing rebuilt:
/// this test's own. `remember_executable` is what the server calls at
/// startup, so this is the same pair of calls the self-update path
/// makes, with the build in between doing nothing -- which is exactly
/// the case that must not restart.
#[test]
fn a_binary_nothing_rebuilt_reports_unchanged() {
remember_executable();
assert!(binary_unchanged());
}
/// The count has to fall to zero on its own, including when a client
/// disappears rather than finishing -- which is a drop either way.
#[test]
fn a_download_is_counted_only_while_it_is_being_sent() {
let downloads = Arc::new(Downloads::default());
assert_eq!(downloads.in_flight(), 0, "nothing is being sent yet");
let first = downloads.start();
let second = downloads.start();
assert_eq!(downloads.in_flight(), 2, "two phones at once");
drop(first);
assert_eq!(downloads.in_flight(), 1, "one finished");
// Dropped without finishing, which is what a client going away
// looks like from here.
drop(second);
assert_eq!(downloads.in_flight(), 0, "a restart may now proceed");
}
#[test]
fn a_missing_file_has_no_identity() {
assert!(identity(Path::new("/nonexistent/dev-updater")).is_none());
}
}
+1409
View File
File diff suppressed because it is too large. Load diff
+151
View File
@@ -0,0 +1,151 @@
//! Locating the Android SDK/NDK tools this server shells out to: `aapt2`
//! (reading a discovered APK's package name and label), and
//! `llvm-strip`/`zipalign`/`apksigner` (the slim-APK pipeline in
//! `crate::strip`).
//!
//! Each lookup is resolved lazily and cached, since a miss is a
//! configuration problem worth reporting with the path it looked at rather
//! than a bare "command not found" from the failed spawn.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
/// This user's home directory, or an empty path when there is none.
///
/// Not a panic: every use of this is a path that is then checked for
/// existence and reported with the path it looked at, so a machine with no
/// `HOME` gets "debug keystore not found at ..." rather than a crash at
/// startup. Uses the same `std::env::home_dir` the rest of the crate does,
/// so there is one answer to where home is.
fn home_dir() -> PathBuf {
std::env::home_dir().unwrap_or_default()
}
/// Honors `$ANDROID_HOME`/`$ANDROID_SDK_ROOT` before falling back to
/// Android Studio's default location on Linux, so this works on a machine
/// that keeps its SDK somewhere else without needing a flag.
fn sdk_root() -> PathBuf {
std::env::var_os("ANDROID_HOME")
.or_else(|| std::env::var_os("ANDROID_SDK_ROOT"))
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join("Android/Sdk"))
}
/// Highest-versioned entry directly under `dir`, by plain name sort --
/// good enough for SDK/NDK release directories, which sort correctly as
/// strings within a single major version scheme.
fn newest_child(dir: &Path) -> Option<PathBuf> {
let mut children: Vec<PathBuf> = std::fs::read_dir(dir)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
children.sort();
children.pop()
}
/// The newest installed `build-tools/<version>/` directory, which is where
/// `aapt2`, `zipalign` and `apksigner` live.
pub fn build_tools_dir() -> Result<&'static Path> {
static DIR: OnceLock<Result<PathBuf, String>> = OnceLock::new();
match DIR.get_or_init(|| {
let base = sdk_root().join("build-tools");
newest_child(&base).ok_or_else(|| {
format!(
"no Android SDK build-tools found under {} -- install one \
(`android sdk install build-tools/37.0.0`, or source \
../app/android-env.sh, which does it for you)",
base.display(),
)
})
}) {
Ok(dir) => Ok(dir),
Err(message) => bail!("{message}"),
}
}
pub fn aapt2_path() -> Result<PathBuf> {
let path = build_tools_dir()?.join("aapt2");
if !path.is_file() {
bail!("aapt2 not found at {}", path.display());
}
Ok(path)
}
/// `llvm-strip` ships with the NDK, not the SDK, so this looks in the two
/// places an NDK is normally installed on top of an explicit
/// `$ANDROID_NDK_HOME`. Only the slim-APK pipeline needs it -- an app with
/// no native libraries never reaches this.
pub fn llvm_strip_path() -> Result<PathBuf> {
static PATH: OnceLock<Result<PathBuf, String>> = OnceLock::new();
match PATH.get_or_init(|| {
let roots: Vec<PathBuf> = std::env::var_os("ANDROID_NDK_HOME")
.map(PathBuf::from)
.into_iter()
.chain(newest_child(&sdk_root().join("ndk")))
.chain(newest_child(&home_dir().join("Android")).filter(|path| {
path.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with("android-ndk-"))
}))
.collect();
for root in &roots {
let path = root.join("toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip");
if path.is_file() {
return Ok(path);
}
}
Err(format!(
"llvm-strip not found -- looked under {}. Install an NDK, or point \
$ANDROID_NDK_HOME at one. Only apps with native libraries need it.",
if roots.is_empty() {
"no NDK install (checked $ANDROID_NDK_HOME, $ANDROID_HOME/ndk, ~/Android)"
.to_string()
} else {
roots
.iter()
.map(|root| root.display().to_string())
.collect::<Vec<_>>()
.join(", ")
},
))
}) {
Ok(path) => Ok(path.clone()),
Err(message) => bail!("{message}"),
}
}
/// The debug keystore every locally-built debug APK is already signed with,
/// reused to re-sign a stripped copy so it still installs over the original.
pub fn debug_keystore_path() -> Result<PathBuf> {
let path = home_dir().join(".android/debug.keystore");
if !path.is_file() {
bail!(
"debug keystore not found at {} -- build any Android app once to have \
Gradle create it",
path.display(),
);
}
Ok(path)
}
/// Runs `cmd`, turning a nonzero exit into an error naming the program and
/// its stderr rather than leaving it to be discovered as a corrupt output
/// file later.
pub fn run_checked(cmd: &mut std::process::Command) -> Result<()> {
let program = cmd.get_program().to_string_lossy().into_owned();
let output = cmd
.output()
.with_context(|| format!("failed to spawn {program}"))?;
if !output.status.success() {
bail!(
"{program} failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr),
);
}
Ok(())
}
+258
View File
@@ -0,0 +1,258 @@
#!/bin/sh
# The service script dev-updater uses for a component that declares
# `service: Managed("...")` instead of carrying one of its own.
#
# <this> --name NAME --exec "COMMAND ARGS" <subcommand>
#
# Installed by dev-updater into its own data directory and run exactly like
# a project's own script, so nothing downstream can tell the two apart:
# same subcommands, same four status words, same `logs` contract. This file
# is not special, it is just the copy nobody has to maintain.
#
# It runs with its working directory already set to the component's -- the
# same resolution a project's own script gets -- so `pwd` is the directory
# the service should run in and COMMAND is resolved against it. That is why
# there is no --dir.
#
# dev-updater's own `server/service` is a wrapper over this, so there is
# one implementation of the init-system half rather than two that drift --
# which they had, four functions byte-identical and half the rest the same.
# It follows that a change here changes how *this server's own service* is
# defined, and that the OpenRC branch nobody can test from the development
# VM now has one place to be wrong instead of two.
set -eu
NAME=
EXEC=
while [ $# -gt 1 ]; do
case "$1" in
--name) NAME=$2; shift 2 ;;
--exec) EXEC=$2; shift 2 ;;
*) echo "unknown option $1" >&2; exit 2 ;;
esac
done
SUBCOMMAND="${1:-}"
[ -n "$NAME" ] || { echo "--name is required" >&2; exit 2; }
[ -n "$EXEC" ] || { echo "--exec is required" >&2; exit 2; }
# The directory this was run in, which is the component's own. Captured
# before anything can change it, and absolute because a unit file cannot
# hold a relative path.
DIRECTORY=$(pwd)
# Resolved the same way a shell resolves a program name: one written with a
# path separator is a file here, anything else is found on PATH. Split on
# whitespace deliberately -- the same limit the config format has, and for
# the same reason.
# shellcheck disable=SC2086 # word splitting is what turns EXEC into argv
set -- $EXEC
PROGRAM=$1
shift
# Kept as one string because both managers want it that way; the
# `${VAR:+ }` at the use sites is what stops a service with no arguments
# getting a trailing space welded onto its ExecStart.
ARGUMENTS=$*
case "$PROGRAM" in
*/*) PROGRAM="$DIRECTORY/${PROGRAM#./}" ;;
esac
detect() {
if command -v systemctl >/dev/null 2>&1 &&
systemctl --user show-environment >/dev/null 2>&1; then
echo systemd
elif command -v rc-service >/dev/null 2>&1; then
# `rc-service --user --help` is the real probe for whether this
# OpenRC has user services at all, but it *fails when
# XDG_RUNTIME_DIR is unset* -- so probing first reports "no service
# manager here" for a machine that has one and is merely missing a
# variable, sending the reader to look for something that is
# installed. Only trust the probe once the variable is set; without
# it, say openrc and let the check below name the real problem.
if [ -n "${XDG_RUNTIME_DIR:-}" ] && ! rc-service --user --help >/dev/null 2>&1; then
echo none
else
echo openrc
fi
else
echo none
fi
}
MANAGER=$(detect)
if [ "$MANAGER" = none ]; then
echo "No user-service manager here: this needs systemd with a user bus," >&2
echo "or OpenRC 0.60+ (older ones have no --user). Run $NAME by hand." >&2
exit 1
fi
if [ "$MANAGER" = openrc ] && [ -z "${XDG_RUNTIME_DIR:-}" ]; then
echo "XDG_RUNTIME_DIR is unset, and OpenRC stores user-service state in it." >&2
echo "Set it at login (elogind or pam_xdg) and try again." >&2
exit 1
fi
SYSTEMD_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$NAME.service"
OPENRC_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/rc/init.d/$NAME"
LOG_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater/services/$NAME"
LOG="$LOG_DIR/$NAME.log"
PREVIOUS_LOG="$LOG.1"
rotate_log() {
mkdir -p "$LOG_DIR"
[ -f "$LOG" ] && mv -f "$LOG" "$PREVIOUS_LOG"
: > "$LOG"
}
installed() {
case "$MANAGER" in
systemd) [ -f "$SYSTEMD_UNIT" ] ;;
openrc) [ -f "$OPENRC_UNIT" ] ;;
esac
}
do_install() {
if [ ! -x "$PROGRAM" ] && ! command -v "$PROGRAM" >/dev/null 2>&1; then
echo "No built program at $PROGRAM -- build it first." >&2
exit 1
fi
mkdir -p "$LOG_DIR"
case "$MANAGER" in
systemd)
mkdir -p "$(dirname "$SYSTEMD_UNIT")"
cat > "$SYSTEMD_UNIT" <<UNIT
[Unit]
Description=$NAME (installed by dev-updater)
[Service]
ExecStart=$PROGRAM${ARGUMENTS:+ $ARGUMENTS}
Restart=on-failure
WorkingDirectory=$DIRECTORY
StandardOutput=append:$LOG
StandardError=append:$LOG
[Install]
WantedBy=default.target
UNIT
systemctl --user daemon-reload
systemctl --user enable "$NAME" >/dev/null
;;
openrc)
mkdir -p "$(dirname "$OPENRC_UNIT")"
cat > "$OPENRC_UNIT" <<UNIT
#!/sbin/openrc-run
name="$NAME"
description="$NAME (installed by dev-updater)"
command="$PROGRAM"
command_args="$ARGUMENTS"
command_background=true
directory="$DIRECTORY"
pidfile="\${XDG_RUNTIME_DIR}/$NAME.pid"
output_log="$LOG"
error_log="$LOG"
UNIT
chmod +x "$OPENRC_UNIT"
rc-update --user add "$NAME" >/dev/null 2>&1 || true
;;
esac
}
do_uninstall() {
installed || return 0
case "$MANAGER" in
systemd)
systemctl --user disable --now "$NAME" >/dev/null 2>&1 || true
rm -f "$SYSTEMD_UNIT"
systemctl --user daemon-reload
;;
openrc)
rc-service --user "$NAME" stop >/dev/null 2>&1 || true
rc-update --user del "$NAME" >/dev/null 2>&1 || true
rm -f "$OPENRC_UNIT"
;;
esac
}
control() {
installed || { echo "$NAME is not installed" >&2; exit 1; }
case "$MANAGER" in
systemd) systemctl --user "$1" "$NAME" ;;
openrc) rc-service --user "$NAME" "$1" ;;
esac
}
case "$SUBCOMMAND" in
install) do_install ;;
uninstall) do_uninstall ;;
start | restart)
rotate_log
control "$SUBCOMMAND"
;;
stop) control "$SUBCOMMAND" ;;
logs)
[ -f "$LOG" ] && echo "$LOG"
[ -f "$PREVIOUS_LOG" ] && echo "$PREVIOUS_LOG"
exit 0
;;
status)
if ! installed; then
echo not-installed
exit 0
fi
case "$MANAGER" in
systemd)
if systemctl --user --quiet is-active "$NAME"; then
echo running
elif systemctl --user --quiet is-failed "$NAME"; then
echo failed
else
echo stopped
fi
;;
openrc)
# The exit code, not the text. `rc-service status` prints
# its status line to *stderr*, so the obvious check --
# discard stderr, grep stdout for "crashed" -- throws away
# the very word it is looking for, matches nothing, and
# reports a service that fell over as `stopped`. That is
# precisely the lie the `failed` state was added to
# prevent, and it was measured on OpenRC 0.63.3 rather
# than reasoned about.
#
# The codes also distinguish "could not find out", which
# no amount of reading the text can: an uninitialised user
# softlevel makes every call fail, and that is not a state
# the service is in.
#
# Assigned through `|| code=$?` because `set -e` would
# otherwise kill this script before it could read a code:
# every answer except "running" is a non-zero exit.
code=0
rc-service --user "$NAME" status >/dev/null 2>&1 || code=$?
case "$code" in
0) echo running ;;
3) echo stopped ;;
32) echo failed ;;
*)
if [ ! -f "${XDG_RUNTIME_DIR:-}/openrc/softlevel" ]; then
echo "OpenRC has no user softlevel at" \
"${XDG_RUNTIME_DIR:-\$XDG_RUNTIME_DIR}/openrc/softlevel," \
"so rc-service --user cannot answer for $NAME." >&2
else
echo "rc-service could not report on $NAME (exit $code)." >&2
fi
# Non-zero, and no status word: this server shows
# "couldn't check", which is the truth. Printing a
# state here would be inventing one.
exit 1
;;
esac
;;
esac
;;
*)
echo "usage: $0 --name N --exec C install|uninstall|start|stop|restart|status|logs" >&2
exit 2
;;
esac
+541
View File
@@ -0,0 +1,541 @@
//! Driving a project's long-running server through the script it carries.
//!
//! This server knows nothing about systemd or OpenRC and deliberately
//! never will: which init system is present, and how a unit gets written
//! into it, is knowledge that belongs where the service does. A project
//! declares one script and this runs `<script> <subcommand>` -- `install`,
//! `uninstall`, `start`, `stop`, `restart`, `status`, `logs`.
//!
//! `status` is the one with a contract, because five answers have to be
//! told apart and three of them are not failures: see [`ServiceState`].
//!
//! Nothing here ever runs on the manifest path. Asking a service manager
//! costs a process spawn, and the manifest is fetched on every open,
//! resume and Refresh -- so the answer is fetched in the background and
//! read from [`ServiceChecks`], exactly as `git::RemoteChecks` does for
//! remotes.
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::Serialize;
use crate::config::{Command, Component, Service};
/// What a service script says about its service.
///
/// Four states rather than a boolean, because each is a different thing
/// to offer and a different thing to say. "Not installed" gets an Install
/// button and no controls; "stopped" gets Start; "failed" gets Start too,
/// but must not be *described* as stopped.
///
/// `Failed` exists because its absence made the card lie. A service that
/// fell over is not stopped -- stopped is a state somebody chose, and
/// reading "stopped" about a crash sends you looking for who stopped it.
/// The only alternative a script had was to exit non-zero, which reads as
/// "couldn't check" and is equally wrong: it found out perfectly well, it
/// just had no word for the answer.
///
/// A fifth answer, "the script could not tell us", is still the `Err`
/// case rather than a variant: that one is not a state the service is in,
/// it is this server failing to find out.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ServiceState {
Running,
Stopped,
/// Installed, not running, and not because anybody asked -- it exited
/// non-zero or was killed.
Failed,
NotInstalled,
}
impl ServiceState {
/// The word a script prints on stdout. Deliberately words rather than
/// exit codes: a script that returns 3 has to be read against a table
/// nobody remembers, and the exit status is wanted for the separate
/// question of whether the script worked at all.
fn parse(output: &str) -> Option<Self> {
match output.trim() {
"running" => Some(Self::Running),
"stopped" => Some(Self::Stopped),
"failed" => Some(Self::Failed),
"not-installed" => Some(Self::NotInstalled),
_ => None,
}
}
}
/// The command that drives this component's service, whichever way it
/// declared one.
///
/// The single place the two variants of [`Service`] become the same thing.
/// Everything downstream takes a command and runs `<command> <subcommand>`,
/// so nothing but this function knows that a built-in script exists -- and
/// a component that switches from its own script to the built-in one
/// changes nothing anywhere else.
///
/// `None` covers both a component that is not a server and one that
/// declares no service, which are the same thing to every caller: there
/// is nothing to drive.
pub fn driver(key: &str, component: &Component) -> Option<Command> {
match component.service()? {
Service::Script(script) if !script.is_empty() => Some(script.clone()),
Service::Managed(run) if !run.is_empty() => Some(Command::from_words(vec![
crate::shipped::service_default()
.to_string_lossy()
.into_owned(),
"--name".to_string(),
unit_name(key, component.name()),
"--exec".to_string(),
run.to_line(),
])),
// Declared empty, which says the same as not declaring it.
Service::Script(_) | Service::Managed(_) => None,
}
}
/// What a managed service is called to its service manager.
///
/// The project key and the component, because a service manager's names
/// are one flat namespace across every project on the machine and
/// "backend" is not a name two projects can share. A project that wants to
/// choose its own name carries its own script, which is one of the things
/// that is for.
fn unit_name(key: &str, component: &str) -> String {
format!("{key}-{component}")
}
/// How long a service command is given before it is given up on.
///
/// Same reasoning as the git remote timeout: a script that hangs -- on a
/// password prompt it should never have shown, most likely -- must not
/// hold a slot forever. Longer than a status check needs, because
/// `install` may be writing units and enabling them.
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Runs one subcommand of `script`, returning its stdout.
///
/// Stdin is closed rather than inherited. A script that prompts for a
/// password gets end-of-file and fails, instead of hanging until the
/// timeout with a card stuck on "installing" -- the same bargain git's
/// `BatchMode` makes.
pub fn run(
script: &Command,
project: &Path,
cwd: Option<&Path>,
subcommand: &str,
) -> Result<String, String> {
let mut child = script
.to_process(project, cwd, &[subcommand])?
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run the service script: {err}"))?;
let deadline = std::time::Instant::now() + TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(_)) => {
let output = child
.wait_with_output()
.map_err(|err| format!("reading the service script's output: {err}"))?;
return if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
// The script's own words, which is what the card shows
// -- it knows why it could not do the thing and this
// does not.
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{subcommand} failed ({})", output.status)
} else {
first_line(&stderr)
})
};
}
Ok(None) if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"{subcommand} took longer than {}s and was stopped -- a service script must \
never wait for input",
TIMEOUT.as_secs()
));
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
Err(err) => return Err(format!("waiting on the service script: {err}")),
}
}
}
/// Runs one subcommand and does **not** wait for it, in its own process
/// group.
///
/// For the one case where the caller is the target: this server asking its
/// own manager to restart it. Both halves matter.
///
/// *Not waiting*, because the thing being waited for is a restart of this
/// process -- there is no result to collect, and blocking a thread on it
/// only creates something for the stop to interrupt.
///
/// *Its own process group*, because on OpenRC `restart` is a shell script
/// doing stop-then-start, run as a child of the very process it is
/// stopping. Waiting on it deadlocks: this server's shutdown waits for its
/// children, and the restart's stop phase waits for this server to exit.
/// Neither moves, `start-stop-daemon` gives up with "1 process refused to
/// stop", and the restart aborts *before* the start ever runs -- which is
/// what left one service parked in `stopping` with the phone unable to
/// reach anything.
///
/// Reproduced on OpenRC 0.63.3, and the reproduction is worth knowing:
/// **it only bites a server that shuts down gracefully.** A test process
/// that dies instantly on SIGTERM passes -- the restart child is orphaned
/// and finishes the job -- so the toy version of this test says everything
/// is fine. Anything that drains its work first, which is every real
/// server, deadlocks.
///
/// systemd does not have the problem at all, because there a restart is a
/// job the daemon owns and the client is free to die. That difference is
/// precisely why this was not caught here.
pub fn spawn_detached(
script: &Command,
project: &Path,
cwd: Option<&Path>,
subcommand: &str,
) -> Result<(), String> {
script
.to_process(project, cwd, &[subcommand])?
.stdin(std::process::Stdio::null())
// Inherited, so whatever the script says about a failed restart
// lands in this server's log -- the one place it can still be read
// afterwards.
.process_group(0)
.spawn()
.map(|_| ())
.map_err(|err| format!("failed to run the service script: {err}"))
}
/// Asks `script` what state its service is in.
pub fn status(
script: &Command,
project: &Path,
cwd: Option<&Path>,
) -> Result<ServiceState, String> {
let output = run(script, project, cwd, "status")?;
ServiceState::parse(&output).ok_or_else(|| {
format!("status printed {output:?}, not running, stopped, failed or not-installed")
})
}
/// Where a component's own log files are, newest first.
///
/// The script both arranges the logging and reports it: neither service
/// manager writes a file by default -- systemd goes to the journal,
/// OpenRC's backgrounded output goes nowhere -- so a unit has to be
/// written to redirect, and only the script knows how. That is the whole
/// point of asking it: this server needs no service-manager-specific code
/// at all, it just gets paths.
///
/// **Not supporting logs is a first-class answer.** A script that prints
/// nothing, or exits non-zero, means "no logs from me" and the card
/// simply offers no button. An older script that has never heard of
/// `logs` falls through to its usage case and exits non-zero, which lands
/// in the same place -- so this is additive and no script has to change
/// before the server does.
///
/// The paths are reported rather than the contents. What to do with a log
/// -- how much of it, which generation -- is this server's business and a
/// phone's, not the script's.
pub fn logs(script: &Command, project: &Path, cwd: Option<&Path>) -> Vec<PathBuf> {
let Ok(output) = run(script, project, cwd, "logs") else {
return Vec::new();
};
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
// Relative paths resolve against the project, the same rule
// the script's own command follows.
let path = Path::new(line);
if path.is_absolute() {
path.to_path_buf()
} else {
project.join(path)
}
})
.collect()
}
/// A script's failures run to a paragraph; a card gets a line. The rest is
/// in this server's log.
fn first_line(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(text)
.to_string()
}
/// What each server component was last found to be doing, refreshed off
/// the request path.
///
/// The typed face of [`crate::checks`], which is where the mechanism
/// lives: the list must not wait on a process spawn, so the answer lands
/// on the next look and the card says it is still being worked out until
/// it does. Keyed by project key and component name, because the actions
/// are per component.
#[derive(Default, Clone)]
pub struct ServiceChecks(Arc<crate::checks::Checks<(String, String), Answer>>);
/// What is known about one component, as one value.
///
/// Both parts come from the same trip, because both cost the same process
/// spawn and the manifest must not pay it per request. They are kept
/// together rather than as two checks so that a status that failed cannot
/// leave the logs looking like they belong to a different moment.
#[derive(Default, Clone)]
pub struct Answer {
state: Option<ServiceState>,
/// The log files this component's script reported, newest first. The
/// card only needs to know *whether* there are logs; reading one is an
/// explicit tap and can pay for its own ask.
logs: Vec<PathBuf>,
}
/// One component to ask about: which project it belongs to, where that
/// project is, and the script.
pub struct Target {
pub key: String,
pub component: String,
pub project: PathBuf,
pub cwd: Option<PathBuf>,
pub script: Command,
}
impl ServiceChecks {
pub fn state(&self, key: &str, component: &str) -> Option<ServiceState> {
self.0.answer(&id(key, component)).and_then(|it| it.state)
}
pub fn is_checking(&self, key: &str, component: &str) -> bool {
self.0.is_checking(&id(key, component))
}
/// The log files last reported for this component. Empty before
/// anything has been asked, and for a script that offers none.
pub fn logs(&self, key: &str, component: &str) -> Vec<PathBuf> {
self.0
.answer(&id(key, component))
.map(|it| it.logs)
.unwrap_or_default()
}
pub fn error(&self, key: &str, component: &str) -> Option<String> {
self.0.error(&id(key, component))
}
/// Asks every target that isn't already being asked. Returns at once.
pub fn refresh(&self, targets: Vec<Target>) {
for target in targets {
let id = id(&target.key, &target.component);
self.0.start(id, move |previous| {
let state = status(&target.script, &target.project, target.cwd.as_deref());
// Same trip, because it is the same spawn cost.
let logs = logs(&target.script, &target.project, target.cwd.as_deref());
if let Err(err) = &state {
tracing::warn!("asking {}'s {} failed: {err}", target.key, target.component);
}
// A failed status keeps the previous one and records why,
// so the card can qualify what it shows rather than
// passing a stale state off as current -- while the logs,
// which were found, are kept either way.
crate::checks::Report {
answer: Some(Answer {
state: state
.as_ref()
.ok()
.copied()
.or_else(|| previous.and_then(|previous: Answer| previous.state)),
logs,
}),
error: state.err(),
}
});
}
}
/// Records a state this server just caused, so the card reflects an
/// action without waiting for the next background check. Leaves the
/// logs alone: they are still the ones that were found.
pub fn mark(&self, key: &str, component: &str, state: ServiceState) {
self.0
.update(id(key, component), |answer| answer.state = Some(state));
}
/// Forgets a project's components, for one being removed.
pub fn forget(&self, key: &str) {
self.0.retain(|(project, _)| project != key);
}
}
fn id(key: &str, component: &str) -> (String, String) {
(key.to_string(), component.to_string())
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
fn server(service: Option<Service>) -> Component {
Component::Server {
name: "backend".to_string(),
build: Command::default(),
cwd: None,
stale_when: None,
service,
built_from: None,
}
}
/// The one place the two variants become the same thing, so this is
/// where it is worth pinning down what each turns into.
#[test]
fn both_variants_resolve_to_one_command_to_run() {
// A project's own script is passed through untouched -- it is
// already the thing the contract describes.
let own = Command::from_line("server/service");
assert_eq!(
driver("app", &server(Some(Service::Script(own.clone())))),
Some(own)
);
// Managed becomes the built-in script with the component's
// identity and command as arguments, so the subcommand the caller
// appends still lands last.
let managed = driver(
"app",
&server(Some(Service::Managed(Command::from_line(
"target/release/ai-server --port 8080",
)))),
)
.expect("a managed component has a driver");
let (program, arguments) = managed.split_first().expect("a program");
assert_eq!(
program,
&crate::shipped::service_default()
.to_string_lossy()
.into_owned()
);
assert_eq!(
arguments,
[
"--name",
// The key as well as the component: a service manager's
// names are one namespace across every project here.
"app-backend",
"--exec",
"target/release/ai-server --port 8080",
]
);
}
/// A server with no service, and one whose declaration is empty, are
/// the same answer to every caller: there is nothing to drive.
#[test]
fn nothing_to_drive_is_none_however_it_was_said() {
assert_eq!(driver("app", &server(None)), None);
assert_eq!(
driver("app", &server(Some(Service::Script(Command::default())))),
None
);
assert_eq!(
driver("app", &server(Some(Service::Managed(Command::default())))),
None
);
}
/// The property the OpenRC failure turned on: the script this server
/// asks to restart it must not be something this server then waits on,
/// or the two wait for each other and the start never happens. Its own
/// process group is what makes that structurally true rather than
/// remembered.
///
/// Tested by having the child report its own group, because that is
/// the thing that decides whether a group-directed signal reaches it.
/// Nothing here can test OpenRC itself -- this machine has systemd --
/// so this pins the mechanism rather than the outcome.
#[test]
fn a_detached_child_is_outside_this_process_group() {
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("report");
std::fs::write(
&script,
"#!/bin/sh\nps -o pgid= -p $$ | tr -d ' ' > \"$(dirname \"$0\")/pgid\"\n",
)
.expect("write");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).expect("chmod");
spawn_detached(
&Command::from_line(&script.to_string_lossy()),
dir.path(),
None,
"restart",
)
.expect("spawn");
let reported = dir.path().join("pgid");
let mut waited = 0;
while !reported.is_file() && waited < 100 {
std::thread::sleep(std::time::Duration::from_millis(50));
waited += 1;
}
let child_group: i32 = std::fs::read_to_string(&reported)
.expect("the detached child should have reported its group")
.trim()
.parse()
.expect("a process group id");
// Read the same way the child reported its own, so the two
// numbers are comparable and nothing new is depended on for it.
let ours: i32 = String::from_utf8_lossy(
&std::process::Command::new("ps")
.args(["-o", "pgid=", "-p", &std::process::id().to_string()])
.output()
.expect("ps")
.stdout,
)
.trim()
.parse()
.expect("our own process group id");
assert_ne!(
child_group, ours,
"a child in our own group is one the stop would kill with us"
);
}
/// The three words and nothing else. A script printing something else
/// has a bug, and saying so beats picking whichever state is nearest.
#[test]
fn only_the_three_words_are_states() {
assert_eq!(ServiceState::parse("running"), Some(ServiceState::Running));
assert_eq!(
ServiceState::parse(" stopped\n"),
Some(ServiceState::Stopped)
);
assert_eq!(ServiceState::parse("failed"), Some(ServiceState::Failed));
assert_eq!(
ServiceState::parse("not-installed"),
Some(ServiceState::NotInstalled)
);
assert_eq!(ServiceState::parse("active"), None);
assert_eq!(ServiceState::parse("Running"), None);
assert_eq!(ServiceState::parse(""), None);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Shell scripts compiled into this binary and written out at startup.
//!
//! Two of them so far: the service script a component gets when it
//! declares `Managed`, and the wrapper that reports a Gradle build's
//! progress. They are here for the same reason — the knowledge in them is
//! shared by every project this server builds, so it belongs in the server
//! rather than copied into each project's own scripts, where it drifts.
//!
//! Shipped as *scripts* rather than implemented in Rust because both do
//! things a shell is the right tool for and because both must be runnable
//! by hand: debugging a service script by running it is how the OpenRC
//! branch of it was ever tested at all.
//!
//! Written out at every start rather than when missing. The file is a copy
//! of what is compiled in, so the only question worth asking is whether it
//! matches *this* binary, and rewriting answers it without having to ask —
//! which is the whole point of shipping them, since a stale copy is the
//! drift they exist to prevent, arriving by a slower route.
use std::path::PathBuf;
/// Drives a service for a component declaring `Managed`; see
/// `crate::service`.
const SERVICE_DEFAULT: &str = include_str!("service-default.sh");
/// Wraps a build command that cannot report its own progress; see the
/// script's own header for which can and which cannot.
const BUILD_PROGRESS: &str = include_str!("build-progress.sh");
/// Where a shipped script is written.
///
/// Generated output rather than configuration, so under `XDG_DATA_HOME`
/// beside the logs, on the same reasoning `crate::logs` uses.
pub fn path(name: &str) -> PathBuf {
crate::logs::data_dir().join(name)
}
/// The service script's path, named once so callers do not repeat the
/// string.
pub fn service_default() -> PathBuf {
path("service-default")
}
/// The build-progress wrapper's path, which is handed to build commands as
/// `DEV_UPDATER_PROGRESS`.
pub fn build_progress() -> PathBuf {
path("build-progress")
}
/// Writes every shipped script out, executable. Call once at startup.
pub fn install() -> std::io::Result<()> {
write(&service_default(), SERVICE_DEFAULT)?;
write(&build_progress(), BUILD_PROGRESS)
}
fn write(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// Truncating rather than appending, and the mode is only applied at
// creation -- so it is set explicitly afterwards for the case where
// the file already existed.
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o700)
.open(path)?;
file.write_all(contents.as_bytes())?;
drop(file);
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
}
+194
View File
@@ -0,0 +1,194 @@
//! Produces a sibling `<name>.slim.apk` for an APK with native `.so`
//! debug symbols stripped out of every `lib/**/*.so` (everything else
//! copied byte-for-byte, via `ZipWriter::raw_copy_file` so untouched
//! entries are never decompressed/recompressed), re-signed with the same
//! debug key so it still installs cleanly.
//!
//! Cached against the source APK's (mtime, size) in a `.slim-stamp`
//! sidecar, so it's only regenerated when the underlying build actually
//! changes.
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::UNIX_EPOCH;
use anyhow::{Context, Result};
use crate::sdk::{self, run_checked};
const DEBUG_KEYSTORE_PASS: &str = "android";
const DEBUG_KEY_ALIAS: &str = "androiddebugkey";
/// The path an app should actually be served from: `raw_path` itself when
/// `strip` is off, or its cached `.slim.apk` otherwise (running the strip
/// pipeline first if the cache is stale).
///
/// For the download itself, which is the one request that has to have the
/// exact bytes. Anything that only wants to *describe* the download wants
/// [`serveable_now`] instead.
pub async fn resolve_serveable_path(raw_path: &Path, strip: bool) -> Result<PathBuf> {
if !strip {
return Ok(raw_path.to_path_buf());
}
let raw_path = raw_path.to_path_buf();
tokio::task::spawn_blocking(move || strip_debug_symbols(&raw_path))
.await
.context("strip task panicked")?
}
/// The path an app would be served from *as things stand*, doing no work:
/// the slim copy if one has already been produced, otherwise the raw APK.
///
/// The manifest uses this rather than [`resolve_serveable_path`] because
/// stripping is seconds of zip walking, `llvm-strip` and re-signing, and
/// the manifest is fetched on every open, resume and Refresh. Generating a
/// build artifact to answer a question about sizes is the wrong trade: a
/// card is worth an approximate number, never a stall.
///
/// So the size shown just after a rebuild is the previous slim copy's,
/// until a download regenerates it. That is a far closer answer than the
/// unstripped size, which is the only other thing available without doing
/// the work.
pub fn serveable_now(raw_path: &Path, strip: bool) -> PathBuf {
if !strip {
return raw_path.to_path_buf();
}
let slim = with_suffix(raw_path, ".slim.apk");
if slim.is_file() {
slim
} else {
raw_path.to_path_buf()
}
}
fn strip_debug_symbols(src_path: &Path) -> Result<PathBuf> {
let slim_path = with_suffix(src_path, ".slim.apk");
let stamp_path = with_suffix(src_path, ".slim-stamp");
let metadata =
std::fs::metadata(src_path).with_context(|| format!("stat {}", src_path.display()))?;
let stamp = format!(
"{}:{}",
metadata.modified()?.duration_since(UNIX_EPOCH)?.as_nanos(),
metadata.len(),
);
if slim_path.is_file()
&& std::fs::read_to_string(&stamp_path).ok().as_deref() == Some(stamp.as_str())
{
return Ok(slim_path);
}
let llvm_strip = sdk::llvm_strip_path()?;
let build_tools = sdk::build_tools_dir()?;
tracing::info!(
"stripping native debug symbols from {} ({} bytes)...",
src_path.display(),
metadata.len(),
);
let tmp = tempfile::tempdir()?;
let unaligned_path = tmp.path().join("unaligned.apk");
strip_native_libs(src_path, &unaligned_path, &llvm_strip, tmp.path())?;
let aligned_path = tmp.path().join("aligned.apk");
run_checked(
Command::new(build_tools.join("zipalign"))
.arg("-f")
.arg("-p")
.arg("4")
.arg(&unaligned_path)
.arg(&aligned_path),
)?;
run_checked(
Command::new(build_tools.join("apksigner"))
.arg("sign")
.arg("--ks")
.arg(sdk::debug_keystore_path()?)
.arg("--ks-pass")
.arg(format!("pass:{DEBUG_KEYSTORE_PASS}"))
.arg("--key-pass")
.arg(format!("pass:{DEBUG_KEYSTORE_PASS}"))
.arg("--ks-key-alias")
.arg(DEBUG_KEY_ALIAS)
.arg("--out")
.arg(&slim_path)
.arg(&aligned_path),
)?;
std::fs::write(&stamp_path, &stamp)?;
tracing::info!(
"stripped -> {} ({} bytes)",
slim_path.display(),
std::fs::metadata(&slim_path)?.len(),
);
Ok(slim_path)
}
/// Rewrites `src_path` into `dst_path`, running every `lib/**/*.so` entry
/// through `llvm-strip --strip-debug` and copying everything else as-is.
fn strip_native_libs(
src_path: &Path,
dst_path: &Path,
llvm_strip: &Path,
tmp_dir: &Path,
) -> Result<()> {
let src_file =
std::fs::File::open(src_path).with_context(|| format!("open {}", src_path.display()))?;
let mut archive = zip::ZipArchive::new(src_file).context("read apk as zip")?;
let dst_file = std::fs::File::create(dst_path)
.with_context(|| format!("create {}", dst_path.display()))?;
let mut writer = zip::ZipWriter::new(dst_file);
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.with_context(|| format!("read entry {i}"))?;
let name = file.name().to_string();
if !(name.starts_with("lib/") && name.ends_with(".so")) {
writer
.raw_copy_file(file)
.with_context(|| format!("copy {name}"))?;
continue;
}
let mut options =
zip::write::SimpleFileOptions::default().compression_method(file.compression());
if let Some(mode) = file.unix_mode() {
options = options.unix_permissions(mode);
}
if let Some(modified) = file.last_modified() {
options = options.last_modified_time(modified);
}
let mut data = Vec::new();
file.read_to_end(&mut data)
.with_context(|| format!("read {name}"))?;
let so_path = tmp_dir.join(name.replace('/', "_"));
std::fs::write(&so_path, &data)?;
run_checked(Command::new(llvm_strip).arg("--strip-debug").arg(&so_path))
.with_context(|| format!("strip {name}"))?;
let stripped = std::fs::read(&so_path)?;
writer
.start_file(&name, options)
.with_context(|| format!("start {name}"))?;
writer
.write_all(&stripped)
.with_context(|| format!("write {name}"))?;
}
writer.finish().context("finish zip")?;
Ok(())
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}