Give each component build modes and a settings sheet

A component now declares its ways of being built in one list -- `modes:
["release", "debug"]`, the first the default -- and every command it runs
is handed the mode as its last argument, so a project whose script takes
`release` or `debug` names that script once. A field may instead be
written per mode, which is the escape hatch for the commands that cannot
take the word: cargo takes `--release` or nothing, and its profile for
the unoptimised build is called `dev` while the directory it writes is
called `debug`, so no single word serves as both the flag and the path.
A command written per mode is not handed the word as well; it already is
the answer, and a stray argument to a service binary is a process that
will not start.

One list rather than gathering names from whichever fields happened to
mention them is what makes a mode missing from one part unsayable: every
per-mode map is checked against it, so a gap is named rather than
resolved to some other mode's command.

Which mode to build in and whether to strip are the build machine's --
there is one checkout and one set of outputs, so a per-device mode would
have two phones rebuilding over each other silently. Which of the
finished builds a phone installs stays that phone's. Neither choice is
part of the acceptance gate, so both have to be carried across the
components list being rewritten, by acceptance and by the self entry's
startup reconciliation alike; without the second a mode chosen for this
server's own component would not survive the restart that applies it.

A mode switch moves no commit, so `builtMode` is recorded beside
`builtFrom`. Without it a component built in debug and switched to
release reads as current and serves the debug build for ever -- and for
an APK nothing else notices, because the "never built at all" check finds
any variant under the component's directory.

Each component card gains a settings sheet behind a gear at the row's
right-hand end, holding the mode, which build to install, strip, and an
Enrol button. The variant picker moved into it: on the card the two read
as one choice, both saying debug and release, and they are not. The row's
text is now bounded and truncates, so a long status can no longer push
the log and settings buttons off the edge.

`enroll:` is a declared command whose one line of stdout is a URL for the
phone to open after installing -- generic on purpose, run per press since
such a link is one-shot and carries a credential, and in the acceptance
gate because it runs on the build machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 06:22:05 -04:00
1 parent 0b7164bb30
commit 17d873ab7c
16 files changed
+2225 -220

No files matched your search

+166 -15
View File
@@ -416,8 +416,18 @@ impl BuildState {
self.git_pull == git_pull
&& self.git_ipv4 == git_ipv4
&& self.components.len() == components.len()
&& std::iter::zip(&self.components, components)
.all(|(mine, theirs)| mine.same_declaration(theirs))
&& std::iter::zip(&self.components, components).all(|(mine, theirs)| {
// The declaration, plus the one choice that changes what
// this state would actually run. `same_declaration`
// deliberately ignores the chosen mode -- picking one is
// not the project asking for something new -- but this
// state holds a *snapshot* of the components it builds
// from, so a mode changed underneath it would go on
// running the old mode's command from a card reporting
// the new one. Strip is not here: it is decided at
// download, and nothing this state does depends on it.
mine.same_declaration(theirs) && mine.effective_mode() == theirs.effective_mode()
})
}
/// Idempotent: kicks off the build in the background if this app is
@@ -546,6 +556,15 @@ impl BuildState {
/// uncommitted work and sending it to a phone would also be a
/// surprising thing to do with work its author has not committed.
pub fn freshness(&self, component: &Component) -> Freshness {
// Behind for a reason no commit can express: what is on disk was
// built some other way than this component is set to build now.
// Reported before the checkout is consulted at all, because it is
// a fact rather than a comparison -- a clean tree at the very
// commit the debug build was made from still does not make that
// build a release one.
if component.built_in_another_mode() {
return Freshness::Behind;
}
let built = self
.inner
.lock()
@@ -575,6 +594,16 @@ impl BuildState {
if component.build().is_empty() {
return false;
}
// The same fact `freshness` reports as Behind, and it has to be
// asked here too: for an `Apk`, the "never built at all" check
// below finds *any* variant under the component's directory, so a
// debug build sitting there is enough to make a component
// switched to release look built. Nothing else would notice --
// the commit has not moved -- so Update would do nothing and the
// phone would install the debug APK from a card saying release.
if component.built_in_another_mode() {
return true;
}
// Nothing built at all is as far behind as an output gets, and it
// is checked before any rule rather than after: a `staleWhen`
// compares two files *inside* a build, so a component that has
@@ -960,11 +989,18 @@ impl BuildState {
// wants to read afterwards.
let log = crate::logs::open_build_log(&self.key, component.name())
.map(|file| Arc::new(Mutex::new(file)));
// The mode, for a build command written once -- which is how a
// project with a script that takes `release` or `debug` says it,
// and why nothing has to be written twice in that case. Nothing
// for a command written per mode: that command already *is* the
// answer, and handing the word over as well would pass a stray
// argument to a build that never asked for one.
let mode = component.build_mode_argument();
self.run_streaming_command(
component.name(),
component.build(),
component.cwd(),
&[],
mode.as_slice(),
log,
)
}
@@ -1323,22 +1359,30 @@ mod tests {
vec![
Component::Server {
name: "backend".to_string(),
build: crate::config::Command::from_line("true"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
cwd: Some(PathBuf::from("server")),
stale_when: None,
also_watch: Vec::new(),
service: None,
mode: None,
built_from: None,
built_mode: None,
},
Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("true"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
cwd: Some(PathBuf::from("app")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
},
]
}
@@ -1389,13 +1433,20 @@ mod tests {
// pressed, and only the pull closes it.
let accepted = vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("touch built-marker"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch built-marker",
)),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: None,
built_from: None,
built_mode: None,
}];
let state = state_for(&clone, accepted.clone());
@@ -1447,22 +1498,34 @@ mod tests {
let components = vec![
Component::Server {
name: "backend".to_string(),
build: crate::config::Command::from_line("touch backend-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch backend-built",
)),
cwd: Some(PathBuf::from("server")),
stale_when: None,
also_watch: Vec::new(),
service: None,
mode: None,
built_from: None,
built_mode: None,
},
Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("touch app-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch app-built",
)),
cwd: Some(PathBuf::from("app")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
},
];
let state = state_for(root, components);
@@ -1527,16 +1590,21 @@ mod tests {
let components = ["slow", "quick"]
.map(|name| Component::Apk {
name: name.to_string(),
build: crate::config::Command::from_line(match name {
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
"slow" => "./slow.sh",
_ => "touch quick-built",
}),
})),
cwd: Some(PathBuf::from(name)),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
})
.to_vec();
let state = state_for(root, components);
@@ -1598,16 +1666,21 @@ mod tests {
let components = ["broken", "fine"]
.map(|name| Component::Apk {
name: name.to_string(),
build: crate::config::Command::from_line(match name {
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
"broken" => "false",
_ => "true",
}),
})),
cwd: Some(PathBuf::from(name)),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
})
.to_vec();
let state = state_for(root, components);
@@ -1671,23 +1744,37 @@ mod tests {
let components = vec![
Component::Apk {
name: "a".to_string(),
build: crate::config::Command::from_line("touch a-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch a-built",
)),
cwd: Some(PathBuf::from("a")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
},
Component::Apk {
name: "b".to_string(),
build: crate::config::Command::from_line("touch b-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch b-built",
)),
cwd: Some(PathBuf::from("b")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
},
];
let state = state_for(root, components);
@@ -1742,6 +1829,65 @@ mod tests {
state.component_is_stale(component, &state.inner.lock().unwrap().built_from.clone())
}
/// The trap a per-component staleness check walks straight into once
/// modes exist. An APK's "never built at all" test finds *any* build
/// under the component's directory, so a debug APK sitting there is
/// enough to make a component switched to release look built -- and
/// switching modes moves no commit, so nothing else has an opinion.
/// Update would then do nothing and the phone would install the debug
/// build from a card saying release.
///
/// Asserted through `component_is_stale` rather than through
/// `built_in_another_mode` alone, because the rule being right is not
/// the same as the check that decides whether to build asking it.
#[test]
fn a_component_switched_to_another_mode_is_stale_even_with_a_build_on_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
// A build output where an APK actually lands, so the "nothing
// built here" branch is satisfied and cannot be what answers.
let built = root.join("build/outputs/apk/debug");
std::fs::create_dir_all(&built).expect("mkdir");
std::fs::write(built.join("app-debug.apk"), b"not really an apk").expect("write");
let moded = |mode: Option<&str>| {
vec![Component::Apk {
name: "app".to_string(),
modes: vec!["release".to_string(), "debug".to_string()],
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: mode.map(str::to_string),
// Built here once, in debug -- without which there is no
// build of ours for the comparison to be about.
built_from: Some("abc123".to_string()),
built_mode: Some("debug".to_string()),
}]
};
let matching = state_for(root, moded(Some("debug")));
assert!(
!stale(&matching, "app"),
"the build on disk is this mode's, so there is nothing to do",
);
let switched = state_for(root, moded(Some("release")));
assert!(
stale(&switched, "app"),
"a debug build must not satisfy a component set to build release",
);
assert_eq!(
switched.freshness(&switched.components[0]),
Freshness::Behind,
"and the card has to say so rather than reading as current",
);
}
/// The shape of bug a unit test that only calls `component_is_stale`
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
/// the staleness check also wants. Taking it twice on one thread is a
@@ -1893,13 +2039,18 @@ mod tests {
root,
vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("true"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: None,
built_from: Some("0000000000000000000000000000000000000000".to_string()),
built_mode: None,
}],
);
assert!(!stale(&state, "app"), "no checkout means no opinion");
+816 -17
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -41,6 +41,7 @@ mod registry;
mod resources;
mod restart;
mod routes;
mod script;
mod sdk;
mod service;
mod shipped;
+180 -4
View File
@@ -304,6 +304,39 @@ fn component_id(component: &Component) -> ComponentId {
)
}
/// What this machine chose about each component, for carrying across a
/// rewrite of the components list.
///
/// The mirror of [`measured_packages`], and here for the same reason:
/// both acceptance and the self entry's startup reconciliation replace
/// `components` wholesale with what the checkout declares, which is right
/// for everything the project asked for and wrong for everything somebody
/// chose here. Without it, choosing to build this server in `debug`
/// would last exactly until the next restart -- and restarting is how
/// this server is updated, so it would never last at all.
///
/// Keyed the same way for the same reason: once a component's directory
/// decides which builds are its own, a reused name is a different
/// component, and handing it the old one's mode would build something
/// nobody asked for.
fn chosen_settings(components: &[Component]) -> HashMap<ComponentId, crate::config::Choices> {
components
.iter()
.map(|component| (component_id(component), component.choices()))
.collect()
}
fn restore_chosen_settings(
components: &mut [Component],
chosen: &HashMap<ComponentId, crate::config::Choices>,
) {
for component in components {
if let Some(choices) = chosen.get(&component_id(component)) {
component.restore(choices.clone());
}
}
}
fn measured_packages(components: &[Component]) -> HashMap<ComponentId, String> {
components
.iter()
@@ -492,6 +525,11 @@ impl AppState {
.find(|candidate| candidate.name() == component)
{
built.set_built_from(sha);
// Beside the commit and in the same write: the mode is
// half of "what is on disk", and a record of one without
// the other is a component that looks current in a mode
// it was never built in.
built.set_built_mode();
}
Ok(())
});
@@ -604,13 +642,18 @@ impl AppState {
// way to start running its commands.
components: vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::default(),
modes: Vec::new(),
build: crate::config::ByMode::default(),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package,
built_from: None,
built_mode: None,
}],
});
Ok(key)
@@ -661,6 +704,46 @@ impl AppState {
})
}
/// Records what somebody chose for one component from the phone:
/// which declared mode to build it in, and whether to strip it.
///
/// A whole-component write rather than a field at a time, because the
/// phone has just shown a person every setting the component has and
/// what comes back is the complete answer.
///
/// The mode is checked against what the component actually declares.
/// An unknown one is refused rather than stored: `ByMode::get` would
/// fall back to the first mode, so storing it would leave the card
/// naming a mode nothing builds in -- which reads exactly like a
/// setting that took effect.
pub fn set_component_choices(
&self,
key: &str,
component: &str,
mode: Option<String>,
strip: Option<bool>,
) -> Result<()> {
self.update(|config| {
let project = config
.projects
.iter_mut()
.find(|project| project.key == key)
.with_context(|| format!("no app named {key}"))?;
let target = project
.components
.iter_mut()
.find(|candidate| candidate.name() == component)
.with_context(|| format!("{key} has no component named {component}"))?;
if let Some(mode) = &mode
&& !target.modes().iter().any(|name| name == mode)
{
bail!("{component} has no build mode called {mode}");
}
target.choose(mode, strip);
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.
@@ -695,6 +778,7 @@ impl AppState {
.find(|project| project.key == key)
.with_context(|| format!("{key} is built in and has nothing to accept"))?;
let measured = measured_packages(&project.components);
let chosen = chosen_settings(&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
@@ -710,6 +794,12 @@ impl AppState {
component.set_package(package.clone());
}
}
// Accepting is about what the project asks to have run. It is
// not somebody withdrawing the mode they picked or the strip
// they turned off, so those survive it -- exactly as the
// measured package does, and for the same reason: neither is
// part of what was being agreed to.
restore_chosen_settings(&mut project.components, &chosen);
Ok(())
})
}
@@ -775,13 +865,18 @@ fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
None => {
components.push(Component::Apk {
name: "app".to_string(),
build: crate::config::Command::default(),
modes: Vec::new(),
build: crate::config::ByMode::default(),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
});
"app".to_string()
}
@@ -791,6 +886,14 @@ fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
.projects
.iter_mut()
.find(|project| project.key == SELF_KEY);
// Carried across the row being rebuilt, like `gitIpv4` below: what is
// *derived* here is what the checkout declares, and a mode chosen for
// this server's own component is not that. Restarting is how this
// server takes an update, so a choice that did not survive a restart
// would not survive being acted on.
if let Some(existing) = existing.as_ref() {
restore_chosen_settings(&mut components, &chosen_settings(&existing.components));
}
let derived = ProjectConfig {
key: SELF_KEY.to_string(),
label: declared.label.unwrap_or_else(|| SELF_LABEL.to_string()),
@@ -946,13 +1049,18 @@ mod tests {
fn asking_for(command: &str) -> Vec<Component> {
vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line(command),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(command)),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: None,
built_from: None,
built_mode: None,
}]
}
@@ -964,6 +1072,16 @@ mod tests {
.expect("write");
}
/// A component's declaration in the project file, with modes.
fn write_moded_request(project: &Path) {
std::fs::write(
project.join(crate::config::PROJECT_CONFIG_FILE),
"components: [Apk(name: \"app\", modes: [\"release\", \"debug\"], \
build: {\"release\": \"r\", \"debug\": \"d\"})],\n",
)
.expect("write");
}
/// A live `AppState` holding one configured app pointed at `project`,
/// with nothing accepted yet.
///
@@ -1027,13 +1145,18 @@ mod tests {
fn apk_named(name: &str, cwd: Option<&str>) -> Component {
Component::Apk {
name: name.to_string(),
build: crate::config::Command::default(),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::default()),
cwd: cwd.map(PathBuf::from),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: None,
built_from: None,
built_mode: None,
}
}
@@ -1178,6 +1301,59 @@ mod tests {
);
}
/// Accepting is about what the project asks to have *run*. It is not
/// somebody withdrawing the mode they picked, so the choice survives
/// -- exactly as the measured package does, and it has to be written
/// here rather than asserted from a hand-built config, because what
/// "accepted" means is whatever `approve_declaration` writes.
#[test]
fn a_chosen_mode_survives_the_declaration_being_accepted_again() {
let dir = tempfile::tempdir().expect("tempdir");
write_moded_request(dir.path());
let (_home, state) = state_with(dir.path());
state.approve_declaration("demo").expect("approve");
state
.set_component_choices("demo", "app", Some("debug".to_string()), None)
.expect("choose the debug mode");
assert_eq!(
state.entry("demo").expect("entry").components[0].effective_mode(),
Some("debug"),
);
state.approve_declaration("demo").expect("approve again");
let component = &state.entry("demo").expect("entry").components[0];
assert_eq!(
component.effective_mode(),
Some("debug"),
"accepting must not silently put the build back to the default mode",
);
assert_eq!(component.build().to_line(), "d");
}
/// A mode this component does not declare is refused rather than
/// stored: `ByMode::get` falls back to the first, so storing it would
/// leave the card naming a mode nothing is built in -- which reads
/// exactly like a setting that took effect.
#[test]
fn a_mode_the_component_does_not_declare_is_refused() {
let dir = tempfile::tempdir().expect("tempdir");
write_moded_request(dir.path());
let (_home, state) = state_with(dir.path());
state.approve_declaration("demo").expect("approve");
let refused = state
.set_component_choices("demo", "app", Some("profile".to_string()), None)
.expect_err("there is no such mode");
assert!(
refused.to_string().contains("profile"),
"the refusal should name what was asked for: {refused}",
);
assert_eq!(
state.entry("demo").expect("entry").components[0].effective_mode(),
Some("release"),
"and it must leave the component where it was",
);
}
/// 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
+1 -50
View File
@@ -58,7 +58,7 @@ pub fn read(project: &Path, declaration: &Resources) -> Result<ResourceFacts, St
parse(&text).map_err(|err| format!("in {}: {err}", path.display()))
}
Resources::Script(command) => {
let text = run(project, command)?;
let text = crate::script::capture(project, None, command, SCRIPT_TIMEOUT)?;
parse(&text).map_err(|err| format!("in the output of {}: {err}", command.to_line()))
}
}
@@ -74,55 +74,6 @@ 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,
+159
View File
@@ -22,6 +22,12 @@
//! services again
//! PUT /apps/{key}/settings {gitIpv4}
//! this machine's preferences for it
//! PUT /apps/{key}/components/{name}/settings {mode, strip}
//! this machine's preferences for one
//! component
//! POST /apps/{key}/components/{name}/enroll-link
//! run that component's `enroll:` and
//! answer the URL it printed
//! GET /apps/{key}/components/{name}/logs[?lines=&generation=]
//! what that component wrote
//! POST /apps/{key}/components/{name}/{action}
@@ -98,6 +104,21 @@ pub fn tls_router(state: Arc<AppState>) -> Router {
// action -- axum matches a literal segment ahead of a capture,
// but declaring it first says so to a reader too.
.route("/apps/{key}/components/{name}/logs", get(component_logs))
// Beside `logs` and ahead of `{action}` for the same reason, even
// though the methods already keep them apart: a reader should not
// have to check that `settings` is not an action.
.route(
"/apps/{key}/components/{name}/settings",
put(set_component_settings),
)
// Its own route rather than one more `{action}`: those drive a
// server's service and answer with what the service is doing,
// where this belongs to an APK and answers with a URL. Folding it
// in would have made the action list mean two things.
.route(
"/apps/{key}/components/{name}/enroll-link",
post(enrollment_link),
)
.route(
"/apps/{key}/components/{name}/{action}",
post(service_action),
@@ -236,6 +257,14 @@ struct ManifestApk {
/// would mean running the strip pipeline here; see
/// `strip::serveable_now`.
size: u64,
/// Whether a debug-symbol-stripped copy is what gets served -- what
/// this machine settled on, which is what the download will actually
/// do.
strip: bool,
/// What the *project* asks for, which is only different when somebody
/// overrode it here. Sent so the sheet can say the two differ rather
/// than showing a switch that silently disagrees with the checkout.
strip_declared: bool,
variants: Vec<ManifestVariant>,
}
@@ -332,6 +361,21 @@ struct ManifestComponent {
/// project that keeps nothing.
#[serde(skip_serializing_if = "Option::is_none")]
resources_error: Option<String>,
/// This component can hand the phone a link to open after it is
/// installed, because the project declares a command that prints one.
/// What the link does is the project's business; the button just
/// opens it.
has_enroll_link: bool,
/// Every way this component can be built, in declaration order, with
/// the first the default. Empty for a component that declares one
/// way, which is not a choice -- the settings sheet says so rather
/// than drawing a picker with one entry in it.
modes: Vec<String>,
/// Which of them it is set to build in. Absent exactly when `modes`
/// is empty; when it is not, this is always one of them, including
/// the case where nobody has chosen and it is the first.
#[serde(skip_serializing_if = "Option::is_none")]
mode: Option<String>,
/// What there is to install, for a component that produces an APK.
#[serde(skip_serializing_if = "Option::is_none")]
apk: Option<ManifestApk>,
@@ -415,6 +459,12 @@ impl ManifestComponent {
resources_error: is_server
.then(|| state.resource_checks.error(key))
.flatten(),
// Gated the same way `has_build` is: while a declaration is
// waiting to be accepted the command will not run, so
// offering the button would be offering one that refuses.
has_enroll_link: may_build && component.enroll().is_some(),
modes: component.modes().to_vec(),
mode: component.effective_mode().map(str::to_string),
apk: match is_server {
true => None,
false => Some(ManifestApk::read(state, key, entry, component).await?),
@@ -455,6 +505,8 @@ impl ManifestApk {
.map(|apk| epoch_secs(apk.modified))
.unwrap_or(0.0),
size,
strip: component.strip(),
strip_declared: component.strip_declared(),
variants: entry
.variants(component)
.into_iter()
@@ -1220,6 +1272,113 @@ async fn set_settings(
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct EnrollmentLink {
url: String,
}
/// How long a project's `enroll:` command is given before it is given up
/// on. Somebody is holding a phone waiting for it, so shorter than a
/// service action's minute -- but long enough for a command that has to
/// touch a keystore or a config file on the way.
const ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// Runs one component's `enroll:` and answers the URL it printed, for the
/// phone to open.
///
/// Run on every press rather than cached anywhere. The link a project
/// mints is ordinarily one-shot and carries a credential, so a stored one
/// would be both stale and a secret sitting in a file -- and a project
/// that mints a fresh token each time is exactly the shape this is for.
///
/// One line, and the first: the contract is a single URL on stdout, which
/// makes a project's diagnostics stderr's business. Trimmed rather than
/// parsed -- what a URL means is the phone's business and not this
/// server's, so nothing here inspects the scheme.
async fn enrollment_link(
State(state): State<Arc<AppState>>,
UrlPath((key, name)): UrlPath<(String, String)>,
) -> Result<Json<EnrollmentLink>, ApiError> {
let entry = state.entry(&key).ok_or(ApiError::UnknownApp(key.clone()))?;
// The same gate the build runs behind, asked here rather than
// inherited: this is a command the project declared, so a pull must
// not be able to introduce one that a press then runs.
if entry.pending_declaration().is_some() {
return Err(ApiError::BadRequest(format!(
"{} is asking to have a command accepted before anything of its own runs",
entry.label
)));
}
let component = entry
.components
.iter()
.find(|component| component.name() == name)
.ok_or_else(|| ApiError::UnknownComponent(key.clone(), name.clone()))?;
let command = component
.enroll()
.ok_or_else(|| {
ApiError::BadRequest(format!("{name} does not say how to get an enrolment link"))
})?
.clone();
let project = entry.project_path.clone();
let cwd = component.cwd().map(std::path::Path::to_path_buf);
let printed = tokio::task::spawn_blocking(move || {
crate::script::capture(&project, cwd.as_deref(), &command, ENROLL_TIMEOUT)
})
.await
.context("the enrolment command panicked")?
.map_err(|err| ApiError::Internal(anyhow::anyhow!(err)))?;
let url = printed
.lines()
.next()
.unwrap_or_default()
.trim()
.to_string();
if url.is_empty() {
return Err(ApiError::Internal(anyhow::anyhow!(
"{name}'s enrolment command printed nothing on stdout"
)));
}
Ok(Json(EnrollmentLink { url }))
}
/// What somebody chose for one component on the settings sheet.
///
/// `mode` absent means "no choice, take the first declared one", which is
/// a real answer rather than a missing field -- it is what a component
/// reverts to and what every component starts as. `strip` absent means
/// the same about the project's own declaration.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ComponentSettingsBody {
#[serde(default)]
mode: Option<String>,
#[serde(default)]
strip: Option<bool>,
}
/// This machine's preferences for one component: which declared mode to
/// build it in, and whether to strip what it serves.
///
/// On the build machine rather than on the device, unlike the variant a
/// download names: a mode decides what gets *built*, and there is one
/// checkout and one set of outputs, so two enrolled phones holding
/// different answers would rebuild over each other with nothing on either
/// screen to say why. Which of the finished builds a phone installs stays
/// that phone's business.
async fn set_component_settings(
State(state): State<Arc<AppState>>,
UrlPath((key, name)): UrlPath<(String, String)>,
Json(body): Json<ComponentSettingsBody>,
) -> Result<StatusCode, ApiError> {
mutate(state, move |state| {
state.set_component_choices(&key, &name, body.mode, body.strip)
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn set_roots(
State(state): State<Arc<AppState>>,
Json(body): Json<RootsBody>,
+76
View File
@@ -0,0 +1,76 @@
//! Running a command a project declared and keeping what it printed.
//!
//! Two things here ask a project to answer a question by running
//! something: `resources`, which reads where a project keeps its state,
//! and the enrolment link on an `Apk` component. Both want the same
//! bargain -- stdin closed, a deadline, stdout kept, and a failure that
//! names the command and the first line of its stderr -- so the bargain
//! is written once rather than twice with the second copy quietly
//! drifting.
//!
//! Deliberately *not* what a build runs through. A build's output is
//! streamed as it arrives, because it is long and somebody is watching a
//! progress bar; these are short and their whole value is the string at
//! the end.
use std::path::Path;
use std::time::{Duration, Instant};
use crate::config::Command;
/// Runs `command` in the project and answers 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
/// deadline with a card stuck on whatever it was doing -- the same
/// bargain the service scripts make.
pub fn capture(
project: &Path,
cwd: Option<&Path>,
command: &Command,
timeout: Duration,
) -> Result<String, String> {
use std::io::Read;
let mut child = command
.to_process(project, cwd, &[])?
.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 = Instant::now() + 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 Instant::now() >= deadline => {
let _ = child.kill();
return Err(format!(
"{} did not answer within {}s",
command.to_line(),
timeout.as_secs()
));
}
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
}
}
}
+89 -12
View File
@@ -80,19 +80,42 @@ impl ServiceState {
/// 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![
let service = component.service()?;
// Resolved here, once, from the component's own mode. Which binary a
// managed service runs is exactly what a build mode changes, so a
// second place deciding the mode is a service started from the other
// mode's output -- which looks like a build that did nothing.
let mode = component.effective_mode();
let command = service.command(mode);
if command.is_empty() {
// Declared empty, which says the same as not declaring it.
return None;
}
// The same rule the build follows: a command written once is told
// which mode it is running in, a command written per mode is not told
// twice. It lands *before* the subcommand the caller appends, which
// is what keeps `<command> <subcommand>` the contract every service
// script is written against.
let mode = service.by_mode().mode_argument(mode);
let with_mode = |command: &Command| match mode {
None => command.clone(),
Some(mode) => {
let mut words: Vec<String> = command.to_words();
words.push(mode.to_string());
Command::from_words(words)
}
};
match service {
Service::Script(_) => Some(with_mode(command)),
Service::Managed(_) => 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(),
with_mode(command).to_line(),
])),
// Declared empty, which says the same as not declaring it.
Service::Script(_) | Service::Managed(_) => None,
}
}
@@ -391,19 +414,64 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
use crate::config::ByMode;
fn server(service: Option<Service>) -> Component {
server_with_modes(Vec::new(), service)
}
fn server_with_modes(modes: Vec<String>, service: Option<Service>) -> Component {
Component::Server {
name: "backend".to_string(),
build: Command::default(),
modes,
build: ByMode::One(Command::default()),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
service,
mode: None,
built_from: None,
built_mode: None,
}
}
/// Which binary a managed service runs is exactly what a mode
/// changes, and this is the single place that gets decided -- a
/// second one would build `release` and start `debug`, from a card
/// reporting the mode it was asked for.
#[test]
fn the_mode_decides_which_binary_the_service_runs() {
let mut component = server_with_modes(
vec!["release".to_string(), "debug".to_string()],
Some(Service::Managed(ByMode::Modes(vec![
(
"release".to_string(),
Command::from_line("target/release/backend"),
),
(
"debug".to_string(),
Command::from_line("target/debug/backend"),
),
]))),
);
let exec_of = |component: &Component| {
let words = driver("app", component).expect("a command to drive it");
let (_, args) = words.split_first().expect("the script and its arguments");
let at = args
.iter()
.position(|arg| arg == "--exec")
.expect("--exec is how the built-in script is told what to run");
args[at + 1].clone()
};
// Nobody has chosen, so it is the first declared -- which is what
// makes declaration order worth getting right.
assert_eq!(exec_of(&component), "target/release/backend");
component.choose(Some("debug".to_string()), None);
assert_eq!(exec_of(&component), "target/debug/backend");
}
/// The one place the two variants become the same thing, so this is
/// where it is worth pinning down what each turns into.
#[test]
@@ -412,7 +480,10 @@ mod tests {
// already the thing the contract describes.
let own = Command::from_line("server/service");
assert_eq!(
driver("app", &server(Some(Service::Script(own.clone())))),
driver(
"app",
&server(Some(Service::Script(ByMode::One(own.clone()))))
),
Some(own)
);
@@ -421,9 +492,9 @@ mod tests {
// appends still lands last.
let managed = driver(
"app",
&server(Some(Service::Managed(Command::from_line(
&server(Some(Service::Managed(ByMode::One(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");
@@ -452,11 +523,17 @@ mod tests {
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())))),
driver(
"app",
&server(Some(Service::Script(ByMode::One(Command::default()))))
),
None
);
assert_eq!(
driver("app", &server(Some(Service::Managed(Command::default())))),
driver(
"app",
&server(Some(Service::Managed(ByMode::One(Command::default()))))
),
None
);
}