Files
dev-updater/server/src/registry.rs
T
iris b0e83059a3 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.
2026-08-31 20:31:08 -04:00

1188 lines
49 KiB
Rust

//! The set of apps this server currently serves, and the mutations the
//! updater app can make to it.
//!
//! An entry is the *project*, not a file: the APK underneath it is
//! rediscovered per request (`crate::discover`, microseconds) so a rebuild
//! is picked up with no bookkeeping, and so an entry whose build hasn't
//! happened yet simply reports as not-built instead of erroring.
//!
//! One entry is not configurable: this server's own updater app. It has to
//! exist unconditionally, because it is the only route by which the copy on
//! a phone can ever be replaced -- an updater that can be removed from its
//! own list can strand itself.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use anyhow::{Context, Result, bail};
use crate::build_state::BuildState;
use crate::config::{Component, Config, ProjectConfig, TokenEntry};
use crate::discover::{self, ApkCandidate};
/// The key of the built-in self-update entry. Reserved, so a project that
/// happens to be named `updater` gets `updater-2` rather than silently
/// shadowing it. Not read from anywhere, because it is this server's own
/// route and the phone's stored download name for it.
pub const SELF_KEY: &str = "updater";
/// What to call the self entry, and what it replaces, when there is no
/// checkout to read `.dev-updater.ron` out of -- an installed binary
/// still has to offer itself, and still has to name the package it is.
const SELF_PACKAGE: &str = "com.example.devupdater";
pub(crate) const SELF_LABEL: &str = "Dev Updater";
/// One servable app, resolved from config (or built in, for the self
/// entry). Cheap to clone-by-`Arc` and handed to request handlers whole.
pub struct AppEntry {
pub key: String,
pub label: String,
pub project_path: PathBuf,
/// What this project produces. Ordinarily one `Apk`; a project that
/// also runs a server on the build machine declares that too.
pub components: Vec<Component>,
/// Present only for an entry with a configured build step. An entry
/// without one reports `needsBuild: false` and its prepare/status
/// routes answer 404, so the app never polls them.
pub build: Option<Arc<BuildState>>,
/// True for the self entry, which the app renders without a Remove
/// button -- see this module's own doc comment.
pub built_in: bool,
/// Whether this app opted in to being pulled (`gitPull`).
pub git_pull: bool,
/// Force git's remote commands onto IPv4 -- this machine's choice
/// about this project, not something the project declares.
pub git_ipv4: bool,
/// The accepted `resources:` declaration, if this project makes one.
/// Where the project says its name and its state directories are; see
/// `crate::config::Resources`.
pub resources: Option<crate::config::Resources>,
}
impl AppEntry {
/// The APK component this entry serves, if it has one.
///
/// One for now, and the first wins if a project ever declares two: the
/// download route serves a project, and which of two APKs it meant
/// would need saying. That is a question for whoever adds the second.
fn apk_component(&self) -> Option<&Component> {
self.components
.iter()
.find(|component| matches!(component, Component::Apk { .. }))
}
/// The package this project's APK installs over, once a build has been
/// read. `None` until there has been one -- a project can be added
/// before it has ever been built.
pub fn package(&self) -> Option<&str> {
self.apk_component().and_then(Component::package)
}
/// Whether to serve a stripped copy. Declared, not detected.
pub fn strip(&self) -> bool {
matches!(
self.apk_component(),
Some(Component::Apk { strip: true, .. })
)
}
/// The APK to serve: `requested` if it is still one of this project's
/// builds, else the newest one found under it.
///
/// Which variant a phone wants is that phone's preference, so it
/// arrives with the request rather than being stored here -- two
/// devices enrolled against one server would otherwise change what the
/// other one gets. Checked against the discovered builds, never taken
/// on trust: an arbitrary path would let a phone name any file on disk
/// to be served.
///
/// A request for something that has since been deleted falls back
/// rather than failing -- a `./gradlew clean` shouldn't take an app out
/// of the list, and the fallback is the answer this would have given
/// before any variant was chosen.
pub fn resolve_apk(&self, requested: Option<&Path>) -> Option<ApkCandidate> {
let variants = self.variants();
if let Some(requested) = requested
&& let Some(found) = variants
.iter()
.find(|candidate| candidate.path == requested)
{
return Some(found.clone());
}
variants.into_iter().next()
}
/// Every build found under this project, newest first -- what the app
/// offers when letting the user switch variants.
pub fn variants(&self) -> Vec<ApkCandidate> {
discover::find_apks(&self.project_path)
}
/// The build step this project is asking for that nobody has accepted
/// -- what the phone shows before offering to accept it, and the
/// answer to "may this app's command run right now?".
///
/// Read from the project on every call rather than settled when the
/// entry was built, because the file changes on a **pull**, and a pull
/// writes no config and so rebuilds no entries. Caching it meant a
/// pull could swap the command out from under a previous acceptance
/// and nothing would notice until an unrelated config write happened.
///
/// Cheap enough for the manifest path: one small read per app, beside
/// the `git status` that path already runs for each of them.
pub fn pending_declaration(&self) -> Option<Vec<Component>> {
// This server's own project is accepted by construction (see
// `reconcile_self`), and the row saying so is only re-derived at
// startup -- so between a pull that changes the declaration and the
// restart that follows it, the two can differ without that meaning
// anything is waiting to be accepted.
if self.built_in {
return None;
}
let declared = crate::config::project_config(&self.project_path);
if declared.components.is_empty()
|| declared.matches_accepted(&self.components, self.resources.as_ref())
{
return None;
}
Some(declared.components)
}
/// This project's resources, as something to read, when it declares
/// any. `None` is a project that says nothing about itself, which is
/// an answer rather than a reason to go looking.
pub fn resource_target(&self) -> Option<crate::resources::Target> {
Some(crate::resources::Target {
key: self.key.clone(),
project: self.project_path.clone(),
declaration: self.resources.clone()?,
})
}
/// Every server component of this project, as something to ask about.
pub fn service_targets(&self) -> Vec<crate::service::Target> {
self.components
.iter()
.filter_map(|component| {
let script = crate::service::driver(&self.key, component)?;
Some(crate::service::Target {
key: self.key.clone(),
component: component.name().to_string(),
project: self.project_path.clone(),
cwd: component.cwd().map(Path::to_path_buf),
script,
})
})
.collect()
}
/// Everything readable about one component, newest first: what its
/// build wrote, then what its service script reports.
///
/// Both kinds in one list because the phone asks one question -- "what
/// can I read about this?" -- and the route that answers it should not
/// care which produced the file. Build logs come first because a
/// component that has just failed to build is the one being looked at.
///
/// The service half is read from the cached background answer by the
/// caller; this only assembles what a direct ask returns, which is why
/// it is used by the log route rather than by the manifest.
/// Where one kind of this component's logs are, newest first.
///
/// Asked one kind at a time rather than returning both, because the
/// caller always knows which it wants and a combined list has to be
/// indexed by something that means two things at once.
pub fn component_logs(&self, name: &str, kind: crate::logs::LogKind) -> Vec<PathBuf> {
let Some(component) = self.component(name) else {
return Vec::new();
};
match kind {
crate::logs::LogKind::Build => crate::logs::build_logs(&self.key, name),
crate::logs::LogKind::Runtime => crate::service::driver(&self.key, component)
.map(|script| crate::service::logs(&script, &self.project_path, component.cwd()))
.unwrap_or_default(),
}
}
/// This project's component of that name, for a route that addresses
/// one directly.
pub fn component(&self, name: &str) -> Option<&Component> {
self.components
.iter()
.find(|component| component.name() == name)
}
/// The name the download is offered under. Prefixed with the key
/// because build outputs are generically named (`app-debug.apk`,
/// `androidApp-debug.apk`) and several apps would otherwise collide in
/// the device's download directory.
pub fn filename(&self, apk: &Path) -> String {
let name = apk.file_name().unwrap_or_default().to_string_lossy();
format!("{}-{name}", self.key)
}
}
/// The packages already read for each component, by name -- what an
/// acceptance has to carry across.
fn measured_packages(components: &[Component]) -> HashMap<String, String> {
components
.iter()
.filter_map(|component| {
Some((
component.name().to_string(),
component.package()?.to_string(),
))
})
.collect()
}
/// Records a freshly read package on whichever component is the APK.
fn set_measured_package(components: &mut [Component], package: String) {
if let Some(component) = components
.iter_mut()
.find(|component| matches!(component, Component::Apk { .. }))
{
component.set_package(package);
}
}
/// The live app list plus the config it was built from, behind one lock.
///
/// Held together rather than separately because every mutation changes both
/// and they must not be observed disagreeing: a request that saw a new
/// config but the old entries would 404 an app the phone was just told
/// exists.
pub struct Registry {
config: Config,
entries: Vec<Arc<AppEntry>>,
}
pub struct AppState {
/// What each checkout's remote last reported, so showing the list
/// costs at most one round trip per repository per half minute.
pub remote_checks: crate::git::RemoteChecks,
/// APK downloads in flight, so a restart of this server waits for
/// them rather than cutting them off. Shared with every `BuildState`,
/// because a build of this project ends in one.
pub downloads: Arc<crate::restart::Downloads>,
/// What each server component was last found to be doing. Beside the
/// remote checks and for the same reason -- asking costs a process
/// spawn, so the manifest reads a cached answer and the refresh that
/// fills it runs off the request.
pub service_checks: crate::service::ServiceChecks,
/// What each project last said about itself -- its name and where its
/// state lives. Beside the other two checks and for the same reason:
/// the `Script` variant spawns a process, so the manifest reads a
/// cached answer and the refresh that fills it runs off the request.
pub resource_checks: crate::resources::ResourceChecks,
/// What a project's APK used to install over, for the ones whose
/// package name has changed under it. Keyed by project key.
///
/// In memory and not in the config, deliberately. Android treats a
/// renamed `applicationId` as an unrelated app, so the old one stays
/// installed and orphaned -- and this exists only so the card can
/// offer to remove it. That is a job with a short life, done once
/// after a rename; carrying it on disk would mean a file remembering
/// names of apps nobody has any more. A restart between the rename and
/// the removal loses the offer, and the orphan is then removed by hand
/// like any other app.
///
/// It needs no clearing protocol: the phone knows what is installed,
/// so it only shows the offer while the old package is actually there.
/// Dropped when the project is, so this can't outlive it.
previous_packages: Mutex<HashMap<String, String>>,
config_path: PathBuf,
registry: RwLock<Registry>,
}
impl AppState {
/// Loads the config and settles the built-in row against
/// `self_project` before anything reads it, which is why nothing after
/// this has to be told which project is this server's own -- the list
/// says so.
pub fn new(config_path: PathBuf, self_project: PathBuf) -> Result<Self> {
let mut config = Config::load(&config_path)?;
// Startup is the only moment the working directory is known, and
// so the only moment a re-clone or a moved repo can be noticed.
if reconcile_self(&mut config, &self_project) {
config.save(&config_path)?;
}
let remote_checks = crate::git::RemoteChecks::default();
let downloads = Arc::new(crate::restart::Downloads::default());
let entries = build_entries(&config, &[], &remote_checks, &downloads);
Ok(Self {
downloads,
remote_checks,
service_checks: crate::service::ServiceChecks::default(),
resource_checks: crate::resources::ResourceChecks::default(),
previous_packages: Mutex::new(HashMap::new()),
config_path,
registry: RwLock::new(Registry { config, entries }),
})
}
pub fn entries(&self) -> Vec<Arc<AppEntry>> {
self.registry.read().unwrap().entries.clone()
}
pub fn entry(&self, key: &str) -> Option<Arc<AppEntry>> {
self.registry
.read()
.unwrap()
.entries
.iter()
.find(|entry| entry.key == key)
.cloned()
}
/// What this project's APK used to install over, if it has been
/// renamed since this server started. See [`Self::previous_packages`].
pub fn previous_package(&self, key: &str) -> Option<String> {
self.previous_packages.lock().unwrap().get(key).cloned()
}
/// Re-reads what `apk` installs over and records it if it has changed.
///
/// Called from the download, which is the only place that can notice:
/// `aapt2` must never run on the manifest path, and a local rebuild
/// with a changed `applicationId` tells this server nothing until the
/// bytes are asked for. Runs off the request so the download is not
/// held up by a process spawn -- the answer is wanted by the *next*
/// manifest, not this one.
pub fn refresh_package(self: &Arc<Self>, key: &str, apk: PathBuf) {
let state = Arc::clone(self);
let key = key.to_string();
tokio::task::spawn_blocking(move || {
let Ok(info) = crate::apkinfo::read(&apk) else {
return;
};
let Some(entry) = state.entry(&key) else {
return;
};
if entry.package() == Some(info.package.as_str()) {
return;
}
if let Some(previous) = entry.package() {
tracing::info!(
"{} now installs {} rather than {previous}",
entry.label,
info.package,
);
state
.previous_packages
.lock()
.unwrap()
.insert(key.clone(), previous.to_string());
}
let update = state.update(|config| {
if let Some(project) = config.projects.iter_mut().find(|p| p.key == key) {
set_measured_package(&mut project.components, info.package);
}
Ok(())
});
if let Err(err) = update {
tracing::warn!("could not record {key}'s package: {err}");
}
});
}
/// Records the commit a component was successfully built from.
///
/// Called from a running build through the callback `routes` hands it,
/// which is why this takes names rather than a handle: the build lives
/// inside this list and must not hold it.
///
/// A failure to write is logged and dropped rather than failing the
/// build. The build genuinely succeeded; all that is lost is knowing
/// what it was built from, which reads as unknown -- the same state a
/// project has before this server has ever built it.
pub fn record_built(&self, key: &str, component: &str, sha: String) {
let written = self.update(|config| {
if let Some(project) = config.projects.iter_mut().find(|p| p.key == key)
&& let Some(built) = project
.components
.iter_mut()
.find(|candidate| candidate.name() == component)
{
built.set_built_from(sha);
}
Ok(())
});
if let Err(err) = written {
tracing::warn!("could not record what {key}'s {component} was built from: {err}");
}
}
pub fn tokens(&self) -> Vec<TokenEntry> {
self.registry.read().unwrap().config.tokens.clone()
}
/// Replaces the enrolled token list. With one device this is rotation:
/// the old hash is invalidated the moment the new config is saved.
pub fn set_tokens(&self, tokens: Vec<TokenEntry>) -> Result<()> {
self.update(|config| {
config.tokens = tokens;
Ok(())
})
}
pub fn repo_roots(&self) -> Vec<PathBuf> {
self.registry.read().unwrap().config.repo_roots.clone()
}
/// Applies `mutate` to the config, then persists it and rebuilds the
/// entry list from it. Every mutation goes through here so that
/// "changed in memory" and "written to disk" can't come apart -- a
/// failed write leaves the previous state intact and reports the error
/// to the phone rather than silently diverging from the file.
fn update<T>(&self, mutate: impl FnOnce(&mut Config) -> Result<T>) -> Result<T> {
let mut registry = self.registry.write().unwrap();
let mut candidate = registry.config.clone();
let result = mutate(&mut candidate)?;
candidate.save(&self.config_path)?;
// Carry existing build state across, so an in-flight build isn't
// forgotten by an unrelated edit to another app.
registry.entries = build_entries(
&candidate,
&registry.entries,
&self.remote_checks,
&self.downloads,
);
registry.config = candidate;
Ok(result)
}
/// Adds the project at `path`, reading its identity out of its newest
/// build. Fails if there's nothing built there yet -- with the path it
/// looked under, since "I pointed it at my repo root instead of the app
/// directory" is the likely mistake and the message should let that be
/// spotted without a log.
pub fn add_app(&self, path: &Path) -> Result<Arc<AppEntry>> {
let project = path
.canonicalize()
.with_context(|| format!("no such directory: {}", path.display()))?;
if !project.is_dir() {
bail!("{} is not a directory", project.display());
}
if let Some(existing) = self
.entries()
.iter()
.find(|entry| entry.project_path == project)
{
bail!(
"{} is already added, as \"{}\"",
project.display(),
existing.label
);
}
let name = project
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "app".to_string());
// Identity comes out of the built APK whenever there is one: it is
// the authority on what will actually be installed. Without one the
// project is still added -- there is simply nothing installed for
// it yet, which is the honest answer and is what lets a project be
// added in order to run the build that produces its first APK.
let declared = crate::config::project_config(&project);
let (label, package) = match discover::find_apks(&project).into_iter().next() {
Some(apk) => {
let info = crate::apkinfo::read(&apk.path)?;
(
info.label.unwrap_or_else(|| name.clone()),
Some(info.package),
)
}
None => (declared.label.unwrap_or_else(|| name.clone()), None),
};
let key = self.update(|config| {
let key = config.unique_key(&name, SELF_KEY);
config.projects.push(ProjectConfig {
key: key.clone(),
label,
project_path: project.clone(),
git_pull: true,
// Nothing this machine has said about it yet.
git_ipv4: false,
// Nothing accepted yet either, including where the project
// says it keeps its state -- that arrives as a request
// alongside the components.
resources: None,
// One component, nothing to run. Whatever the project's own
// file declares arrives as a request to be accepted, the
// same as it always did -- adding a project must not be a
// way to start running its commands.
components: vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::default(),
cwd: None,
stale_when: None,
strip: false,
package,
built_from: None,
}],
});
Ok(key)
})?;
Ok(self
.entry(&key)
.expect("just-added app is in the rebuilt entry list"))
}
pub fn remove_app(&self, key: &str) -> Result<()> {
if key == SELF_KEY {
bail!("{SELF_KEY} is this server's own app and can't be removed");
}
self.update(|config| {
let before = config.projects.len();
config.projects.retain(|app| app.key != key);
if config.projects.len() == before {
bail!("no app named {key}");
}
Ok(())
})?;
// Nothing left for either to be about.
self.previous_packages.lock().unwrap().remove(key);
self.service_checks.forget(key);
self.resource_checks.forget(key);
Ok(())
}
/// Records this machine's preferences for one project.
///
/// Onto that project's own row, including the built-in one, which is
/// what having a row for this server's own project buys: there is one
/// place a preference lives and no card it silently does nothing on.
/// `reconcile_self` carries it across the row being rebuilt.
pub fn set_project_settings(&self, key: &str, git_ipv4: bool) -> Result<()> {
self.update(|config| {
let project = config
.projects
.iter_mut()
.find(|project| project.key == key)
.with_context(|| format!("no app named {key}"))?;
project.git_ipv4 = git_ipv4;
Ok(())
})
}
/// Accepts what this project asks to have run, after a person has read
/// it on the phone. From here on those components are ordinary
/// configuration, indistinguishable from hand-written ones.
///
/// Re-read from the project file rather than taken from the request:
/// what gets stored is what the project actually asks for, so there is
/// no route by which the phone can name a command of its own. The only
/// thing that rewrites that file is a pull the same person just
/// performed.
///
/// Measured values are carried across rather than reset. A declaration
/// says nothing about the package a build produces -- that is read from
/// the APK -- so accepting one must not throw away what was already
/// read, or every acceptance would blank the installed-version check
/// until the next build.
pub fn approve_declaration(&self, key: &str) -> Result<()> {
let entry = self
.entry(key)
.with_context(|| format!("no app named {key}"))?;
let declared = crate::config::project_config(&entry.project_path);
if declared.components.is_empty() {
bail!(
"{} no longer asks for anything -- its {} is gone or unreadable",
entry.label,
crate::config::PROJECT_CONFIG_FILE,
);
}
self.update(|config| {
let project = config
.projects
.iter_mut()
.find(|project| project.key == key)
.with_context(|| format!("{key} is built in and has nothing to accept"))?;
let measured = measured_packages(&project.components);
project.git_pull = declared.git_pull;
// Everything `matches_accepted` compares has to be written
// here, or accepting cannot clear the gate. `resources` joined
// that comparison and this did not, which left any project
// declaring one permanently pending: the write succeeded, the
// card refreshed, and it still asked. Nothing failed, because
// there is no error to report -- the button simply did
// nothing. Add a field to the gate and add it here.
project.resources = declared.resources;
project.components = declared.components;
for component in &mut project.components {
if let Some(package) = measured.get(component.name()) {
component.set_package(package.clone());
}
}
Ok(())
})
}
pub fn set_repo_roots(&self, roots: Vec<PathBuf>) -> Result<()> {
self.update(|config| {
config.repo_roots = roots;
Ok(())
})
}
}
/// Brings the built-in row up to date with what this server actually is,
/// creating it the first time, and reports whether anything changed -- so
/// a start with nothing to say doesn't rewrite the file.
///
/// This project is a row like any other so that everything about a project
/// has one home, this machine's `gitIpv4` included; before it had one,
/// such a setting had to be keyed separately and the one card that is
/// always in the list was the one card it could not be a field on.
///
/// What is *in* the row is still derived, and re-derived here rather than
/// trusted: the path is where this process was started (see main.rs's
/// `self_project`), and the label and components come from the checkout's
/// own declaration. That is what keeps a re-clone or a moved repo fixing
/// itself at the next start instead of leaving the row naming a directory
/// nobody pulls. Only `git_ipv4` is carried across, because it is the one
/// thing here somebody chose rather than something this server measured.
///
/// The declaration is taken as accepted by construction, deliberately not
/// subject to the gate the other entries have. Gating would protect
/// nothing: pulling this repository replaces the binary that would be
/// doing the gating, so whatever arrives is already trusted by the time it
/// could run.
fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
let declared = crate::config::project_config(self_project);
// Asked of the project itself, not of its parent. `git status` answers
// from any subdirectory, so the two agree whenever `app/` is really
// there -- and differ in the one case that matters: a working directory
// that is inside this repo but is not its root (`server/`, say) makes
// the parent test say yes about a project directory that does not
// exist, and the row then claims components from a declaration that is
// not there.
let in_checkout = crate::git::status(self_project).is_some();
// Always has an APK component, and always knows its package. This is
// the one project whose package this server can state without reading
// an APK -- it is this program -- and it has to: an installed binary
// with no checkout has no declaration to read and still has to tell
// the phone what it would be replacing.
let mut components = if in_checkout {
declared.components
} else {
Vec::new()
};
if !components
.iter()
.any(|component| matches!(component, Component::Apk { .. }))
{
components.push(Component::Apk {
name: "app".to_string(),
build: crate::config::Command::default(),
cwd: None,
stale_when: None,
strip: false,
package: None,
built_from: None,
});
}
set_measured_package(&mut components, SELF_PACKAGE.to_string());
let existing = config
.projects
.iter_mut()
.find(|project| project.key == SELF_KEY);
let derived = ProjectConfig {
key: SELF_KEY.to_string(),
label: declared.label.unwrap_or_else(|| SELF_LABEL.to_string()),
project_path: self_project.to_path_buf(),
git_pull: declared.git_pull,
git_ipv4: existing.as_ref().is_some_and(|project| project.git_ipv4),
components,
// Re-derived like everything else in this row: accepted by
// construction, because this server's own declaration is not
// something it has to be asked about.
resources: declared.resources,
};
match existing {
Some(existing) if *existing == derived => false,
Some(existing) => {
*existing = derived;
true
}
// First in the file and first in the list: this is the entry that
// is always there, and the one an installed copy is replaced
// through.
None => {
config.projects.insert(0, derived);
true
}
}
}
/// Rebuilds the entry list from `config`, reusing any `BuildState` from
/// `previous` whose app still has the same prepare configuration -- so an
/// unrelated edit doesn't drop a running build's status on the floor.
fn build_entries(
config: &Config,
previous: &[Arc<AppEntry>],
remote_checks: &crate::git::RemoteChecks,
downloads: &Arc<crate::restart::Downloads>,
) -> Vec<Arc<AppEntry>> {
// Where this server's own project is, according to the row
// `reconcile_self` keeps -- only for the warning below, since
// everything else about that project is read from the row like any
// other's.
let self_project = config
.projects
.iter()
.find(|project| project.key == SELF_KEY)
.map(|project| project.project_path.as_path());
let mut entries = Vec::with_capacity(config.projects.len());
for project in &config.projects {
// The one project whose build this server does know how to run: it
// is this repository. Given a checkout it gets the same
// pull-and-build machinery as anything else, plus a restart --
// pulling rebuilds the binary too, and the point is to end up
// running it.
let is_self = project.key == SELF_KEY;
// Said out loud rather than hidden or dropped. `add_app` refuses a
// path already in the list, and this server's own project is in
// that list, so this can only be left over from when it resolved
// somewhere else (see main.rs's `self_project`). It is worth
// naming because the duplicate is not merely redundant: it is not
// the built-in row, so a build through it would run this project's
// service script and restart *this server* partway through its own
// build, instead of the exec that waits until the build is
// reported.
//
// The card is left alone so it keeps its Remove button, which is
// the only way anyone can act on this from the phone -- and is what
// tells the two cards apart, since the built-in one has none.
if !is_self && Some(project.project_path.as_path()) == self_project {
tracing::warn!(
"{} is this server's own project and is always in the list as the built-in \
{SELF_LABEL} entry, so \"{}\" is a second card for it. Remove that one from \
the app -- it is the one with a Remove button.",
project.project_path.display(),
project.label,
);
}
// A project in a checkout gets pull machinery even with nothing
// configured -- there is somewhere to pull from, which is all a
// Pull button needs. Without a command it pulls and stops; how to
// build is still never guessed.
let in_checkout = crate::git::status(&project.project_path).is_some();
let git_pull = project.git_pull && in_checkout;
// A command can be run wherever it is; a checkout can be pulled
// even with nothing configured. Either is reason enough to have
// build machinery -- without a command it pulls and stops.
//
// Except for this server itself, which is not offered a Pull it has
// nothing to build with: pulling this repository without rebuilding
// leaves the new binary on disk and the old one serving.
let has_command = crate::config::has_command(&project.components);
let build = (has_command || (in_checkout && !is_self)).then(|| {
let components = &project.components;
reuse_build_state(
previous,
&project.key,
components,
git_pull,
project.git_ipv4,
)
.unwrap_or_else(|| {
// For this server's own project the Server component is
// delivered by exec-ing what it just built, which is
// what `is_self` says -- see BuildState::is_self.
BuildState::new(
project.key.clone(),
project.components.clone(),
git_pull,
project.git_ipv4,
project.project_path.clone(),
crate::build_state::Shared {
remote_checks: remote_checks.clone(),
downloads: Arc::clone(downloads),
},
is_self,
)
})
});
entries.push(Arc::new(AppEntry {
git_pull,
git_ipv4: project.git_ipv4,
resources: project.resources.clone(),
key: project.key.clone(),
label: project.label.clone(),
project_path: project.project_path.clone(),
components: project.components.clone(),
build,
built_in: is_self,
}));
}
entries
}
fn reuse_build_state(
previous: &[Arc<AppEntry>],
key: &str,
components: &[crate::config::Component],
git_pull: bool,
git_ipv4: bool,
) -> Option<Arc<BuildState>> {
previous
.iter()
.find(|entry| entry.key == key)?
.build
.as_ref()
.filter(|state| state.matches(components, git_pull, git_ipv4))
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
fn asking_for(command: &str) -> Vec<Component> {
vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line(command),
cwd: None,
stale_when: None,
strip: false,
package: None,
built_from: None,
}]
}
fn write_request(project: &Path, command: &str) {
std::fs::write(
project.join(crate::config::PROJECT_CONFIG_FILE),
format!("components: [Apk(name: \"app\", build: \"{command}\")],\n"),
)
.expect("write");
}
/// A live `AppState` holding one configured app pointed at `project`,
/// with nothing accepted yet.
///
/// Built rather than modelled on purpose: what "accepted" means is
/// whatever [`AppState::approve_declaration`] writes, and a test that
/// assembles the accepted config itself asserts a *copy* of that rule.
/// The copy is what let `resources` join the acceptance comparison
/// without joining the write, so accepting a project that declared one
/// could never clear the gate and no test noticed.
fn state_with(project: &Path) -> (tempfile::TempDir, Arc<AppState>) {
let home = tempfile::tempdir().expect("tempdir");
let config_path = home.path().join("config.ron");
Config {
projects: vec![ProjectConfig {
key: "demo".to_string(),
label: "Demo".to_string(),
project_path: project.to_path_buf(),
git_pull: true,
git_ipv4: false,
components: Vec::new(),
resources: None,
}],
..Config::default()
}
.save(&config_path)
.expect("save");
// Self project pointed at an empty directory: this is about a
// configured app, and the built-in row has nothing to accept.
let state = AppState::new(config_path, home.path().join("self")).expect("state");
(home, Arc::new(state))
}
/// The entry list for one app pointed at `project`, with `accepted`
/// standing for whatever is already in this machine's config.
fn entry_for(project: &Path, accepted: Vec<Component>) -> Arc<AppEntry> {
let config = Config {
projects: vec![ProjectConfig {
key: "demo".to_string(),
label: "Demo".to_string(),
project_path: project.to_path_buf(),
git_pull: true,
git_ipv4: false,
components: accepted,
resources: None,
}],
..Config::default()
};
// No built-in row: this is about a configured app, and leaving it
// out keeps the entry list to the one being asked about.
build_entries(
&config,
&[],
&crate::git::RemoteChecks::default(),
&Arc::new(crate::restart::Downloads::default()),
)
.into_iter()
.find(|entry| entry.key == "demo")
.expect("the configured app")
}
/// The point of the whole mechanism: a project asking for a command
/// does not thereby get to run one.
#[test]
fn a_build_step_a_project_asks_for_waits_until_it_is_accepted() {
let dir = tempfile::tempdir().expect("tempdir");
write_request(dir.path(), "./build.sh");
let pending = entry_for(dir.path(), Vec::new());
assert_eq!(
pending.pending_declaration(),
Some(asking_for("./build.sh"))
);
// Accepted is exactly "the config now says what the project says",
// which is what approve_declaration writes.
let accepted = entry_for(dir.path(), asking_for("./build.sh"));
assert_eq!(accepted.pending_declaration(), None);
assert!(
accepted
.build
.as_ref()
.is_some_and(|build| build.has_command())
);
}
/// Accepting has to clear the gate -- for every project, including
/// one that declares `resources:`.
///
/// The bug this holds shut: `matches_accepted` compares the components
/// *and* the resources declaration, while `approve_declaration` wrote
/// only the components. Accepting therefore stored half of what was
/// being compared, the card came back still asking, and the phone had
/// nothing to show for the press -- the write had succeeded, so there
/// was no error either. It went through the whole of ai-app's card
/// before anybody could accept anything.
#[test]
fn accepting_clears_the_gate_including_the_resources_declaration() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(crate::config::PROJECT_CONFIG_FILE),
"resources: Ron(\"resources.ron\"),\n components: [Apk(name: \"app\", build: \"./build.sh\")],\n",
)
.expect("write");
let (_home, state) = state_with(dir.path());
assert!(
state
.entry("demo")
.expect("entry")
.pending_declaration()
.is_some(),
"nothing accepted yet",
);
state.approve_declaration("demo").expect("approve");
let entry = state.entry("demo").expect("entry");
assert_eq!(
entry.pending_declaration(),
None,
"accepting must clear the gate, not half of it",
);
assert_eq!(
entry.resources,
Some(crate::config::Resources::Ron(PathBuf::from(
"resources.ron"
))),
"the accepted copy is what a later comparison reads",
);
}
/// The case the gate exists for, and the one a cached answer got
/// wrong: the project file changes on a *pull*, which writes no config
/// and so rebuilds no entries. An entry built while the request
/// matched must still notice when it stops matching.
#[test]
fn a_request_that_changes_under_a_built_entry_withdraws_the_acceptance() {
let dir = tempfile::tempdir().expect("tempdir");
write_request(dir.path(), "./build.sh");
let entry = entry_for(dir.path(), asking_for("./build.sh"));
assert_eq!(entry.pending_declaration(), None, "accepted to begin with");
// What a pull bringing a changed .dev-updater.ron does. The entry
// is not rebuilt -- nothing wrote the config -- so this is read
// through the same entry that was just current.
write_request(dir.path(), "./something-else.sh");
assert_eq!(
entry.pending_declaration(),
Some(asking_for("./something-else.sh")),
"a changed request must stop counting as accepted",
);
}
/// A checkout laid out the way this repository is: `<repo>/app` is the
/// self entry's project, and `<repo>` is what gets pulled.
fn self_checkout(root: &Path) -> PathBuf {
let app = root.join("app");
std::fs::create_dir_all(&app).expect("mkdir");
// Committed, not merely initialised: reading a checkout's branch
// is what tells this apart from an installed binary, and a
// repository with no commits has no branch to report.
for args in [
&["init", "-q"][..],
&["config", "user.email", "t@example.com"],
&["config", "user.name", "Test"],
&["commit", "-q", "--allow-empty", "-m", "one"],
] {
let status = std::process::Command::new("git")
.args(args)
.current_dir(root)
.status()
.expect("run git");
assert!(status.success(), "git {args:?}");
}
app
}
/// The built-in entry as it comes out of a config `reconcile_self`
/// has settled -- which is what startup does before anything reads the
/// list.
fn self_entry(app: &Path) -> Arc<AppEntry> {
let mut config = Config::default();
assert!(
reconcile_self(&mut config, app),
"the row has to be written the first time"
);
assert!(
!reconcile_self(&mut config, app),
"and not rewritten with nothing to say"
);
build_entries(
&config,
&[],
&crate::git::RemoteChecks::default(),
&Arc::new(crate::restart::Downloads::default()),
)
.into_iter()
.find(|entry| entry.key == SELF_KEY)
.expect("the self entry always exists")
}
/// The updater describes itself with the same file any other project
/// carries. Describing it twice is how the two descriptions come to
/// disagree.
#[test]
fn the_self_entry_reads_its_own_declaration() {
let dir = tempfile::tempdir().expect("tempdir");
let app = self_checkout(dir.path());
std::fs::write(
app.join(crate::config::PROJECT_CONFIG_FILE),
"label: \"Declared Name\",\n\
components: [\n\
Server(\n\
name: \"server\",\n\
build: \"cargo build --release\",\n\
service: Script(\"../server/service\"),\n\
),\n\
Apk(name: \"app\", build: \"./build-apk.sh\"),\n\
],\n",
)
.expect("write");
let entry = self_entry(&app);
assert_eq!(entry.label, "Declared Name");
// Its own package is this server's, not something a file may
// claim -- it is the one entry that is this program.
assert_eq!(entry.package(), Some(SELF_PACKAGE));
assert!(
entry
.build
.as_ref()
.is_some_and(|build| build.has_command())
);
assert!(entry.git_pull);
// Never waiting to be accepted: pulling this repository replaces
// the binary that would be doing the gating.
assert_eq!(entry.pending_declaration(), None);
}
/// An installed binary has no checkout to read, and still has to offer
/// itself -- it is the only route by which the phone's copy is ever
/// replaced.
#[test]
fn the_self_entry_survives_having_nothing_to_read() {
let dir = tempfile::tempdir().expect("tempdir");
let app = self_checkout(dir.path());
let entry = self_entry(&app);
assert_eq!(entry.label, SELF_LABEL);
assert_eq!(entry.package(), Some(SELF_PACKAGE));
assert!(entry.built_in);
// Nothing declares how to build it, so nothing is guessed.
assert!(entry.build.is_none());
}
/// The point of the built-in project having a row at all: a setting
/// somebody chose from the phone belongs to it like any other
/// project's, and re-deriving the row every start must not take it
/// back off.
#[test]
fn what_this_machine_chose_survives_the_built_in_row_being_re_derived() {
let dir = tempfile::tempdir().expect("tempdir");
let app = self_checkout(dir.path());
let mut config = Config::default();
reconcile_self(&mut config, &app);
config.projects[0].git_ipv4 = true;
assert!(
!reconcile_self(&mut config, &app),
"a setting is not a reason to rewrite"
);
assert!(
config.projects[0].git_ipv4,
"kept: nothing else here is somebody's choice"
);
// And the entry the phone is shown says so.
let entries = build_entries(
&config,
&[],
&crate::git::RemoteChecks::default(),
&Arc::new(crate::restart::Downloads::default()),
);
assert!(entries[0].git_ipv4);
}
/// What is derived stays derived. The symptom this prevents is the one
/// the path being compiled in used to cause: a row naming a directory
/// nobody pulls, on a card that otherwise works.
#[test]
fn a_moved_checkout_repoints_the_built_in_row() {
let dir = tempfile::tempdir().expect("tempdir");
let app = self_checkout(dir.path());
let mut config = Config::default();
reconcile_self(&mut config, Path::new("/nonexistent/app"));
config.projects[0].git_ipv4 = true;
assert!(
reconcile_self(&mut config, &app),
"the path it was started in wins"
);
assert_eq!(config.projects[0].project_path, app);
assert!(config.projects[0].git_ipv4, "and the choice comes with it");
}
/// The ordinary case, which must keep working untouched: a project
/// that asks for nothing is configured entirely from this machine.
#[test]
fn a_project_that_asks_for_nothing_is_left_to_the_local_config() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = entry_for(dir.path(), asking_for("./build.sh"));
assert_eq!(entry.pending_declaration(), None);
assert!(
entry
.build
.as_ref()
.is_some_and(|build| build.has_command())
);
}
}