feat(host/deck): one-click source rebuild — the update.sh run as a transient user unit
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m47s
deb / build-publish-host (push) Successful in 5m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
android / android (push) Successful in 7m2s
docker / builders-arm64cross (push) Successful in 11s
arch / build-publish (push) Failing after 7m33s
ci / rust (push) Successful in 7m35s
docker / deploy-docs (push) Successful in 39s
windows-host / package (push) Failing after 11m1s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 6m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
apple / swift (push) Successful in 4m58s
apple / screenshots (push) Successful in 21m32s
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m47s
deb / build-publish-host (push) Successful in 5m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
android / android (push) Successful in 7m2s
docker / builders-arm64cross (push) Successful in 11s
arch / build-publish (push) Failing after 7m33s
ci / rust (push) Successful in 7m35s
docker / deploy-docs (push) Successful in 39s
windows-host / package (push) Failing after 11m1s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 6m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
apple / swift (push) Successful in 4m58s
apple / screenshots (push) Successful in 21m32s
The U3.1 leg of planning:host-update-from-web-console.md. The Deck's on-device install gets the Update-now button with no opt-in (user-owned, no root): update.sh --pull runs under systemd-run --user so the script's own restart of punktfunk-host can't kill it mid-build (a child in our cgroup would die with us). Outcome without version equality — which a source rebuild can't promise: a failed build leaves the host alive to report it (unit watched to failure, log attached); a successful one restarts us, and the new source_build intent flag makes intent-present-at-boot itself the success signal (the script only restarts the host after a successful install). The console stops treating a live long build as a timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.22.2"
|
||||
"version": "0.22.3"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
|
||||
@@ -101,6 +101,9 @@ pub(crate) fn apply_support() -> &'static str {
|
||||
{
|
||||
"full"
|
||||
}
|
||||
// The Deck source rebuild is user-owned — no helper, no group.
|
||||
#[cfg(target_os = "linux")]
|
||||
detect::InstallKind::SteamosSource => "full",
|
||||
_ => "notify",
|
||||
}
|
||||
}
|
||||
@@ -421,12 +424,15 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply
|
||||
| detect::InstallKind::Sysext
|
||||
| detect::InstallKind::RpmOstree
|
||||
| detect::InstallKind::Pacman
|
||||
| detect::InstallKind::SteamosSource
|
||||
);
|
||||
if !windows_leg && !linux_leg {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if linux_leg {
|
||||
if linux_leg && kind != detect::InstallKind::SteamosSource {
|
||||
// The Deck source rebuild is user-owned and needs no root helper; every other Linux
|
||||
// leg goes through it.
|
||||
if !linux::helper_installed() {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
@@ -519,7 +525,12 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = &asset; // the Linux legs resolve through the package manager
|
||||
linux::run_apply(&target_version, serial, &stage).map(|()| {
|
||||
let run = if detect::detect().0 == detect::InstallKind::SteamosSource {
|
||||
linux::run_apply_steamos(&target_version, serial, &stage)
|
||||
} else {
|
||||
linux::run_apply(&target_version, serial, &stage)
|
||||
};
|
||||
run.map(|()| {
|
||||
// Staged / nothing-to-do wrote a durable result and this process lives
|
||||
// on; an in-place change wrote the intent and our restart is queued.
|
||||
// Either way the in-process job is finished.
|
||||
|
||||
@@ -35,6 +35,13 @@ pub(crate) struct IntentRecord {
|
||||
pub installer_sha256: String,
|
||||
/// Where the installer was told to log (`/LOG=`).
|
||||
pub log_path: String,
|
||||
/// A source rebuild (Steam Deck `update.sh`), where version equality proves nothing —
|
||||
/// the workspace version only moves on bumps. The flow's own ordering carries the
|
||||
/// proof instead: the script restarts the host ONLY after a successful build+install,
|
||||
/// and its failure path is reported live (the host survives a failed build). So an
|
||||
/// intent with this flag still present at boot ⟹ the rebuild succeeded.
|
||||
#[serde(default)]
|
||||
pub source_build: bool,
|
||||
}
|
||||
|
||||
/// The durable outcome of the most recent apply attempt.
|
||||
@@ -124,6 +131,20 @@ pub(crate) fn reconcile(
|
||||
let Some(intent) = intent else {
|
||||
return Reconciled::None;
|
||||
};
|
||||
if intent.source_build {
|
||||
// See `IntentRecord::source_build`: presence at boot IS the success signal; the
|
||||
// running version is the truthful "to" (a rebuild can legitimately keep it).
|
||||
return Reconciled::Success(ResultRecord {
|
||||
ok: true,
|
||||
from: intent.from,
|
||||
to: current_version.to_string(),
|
||||
finished_unix: now_unix,
|
||||
stage: None,
|
||||
error: None,
|
||||
log_path: Some(intent.log_path),
|
||||
staged: false,
|
||||
});
|
||||
}
|
||||
if current_version == intent.to {
|
||||
return Reconciled::Success(ResultRecord {
|
||||
ok: true,
|
||||
@@ -167,6 +188,22 @@ mod tests {
|
||||
started_unix: started,
|
||||
installer_sha256: "ab".repeat(32),
|
||||
log_path: "/logs/update-0.23.200.log".into(),
|
||||
source_build: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_build_intent_is_success_with_the_running_version() {
|
||||
let mut i = intent(0);
|
||||
i.source_build = true;
|
||||
// Same version as `from` (a rebuild without a bump) — still success, and `to` is
|
||||
// what actually runs, not the manifest's label.
|
||||
match reconcile(Some(i), "0.23.100", 10_000_000) {
|
||||
Reconciled::Success(r) => {
|
||||
assert!(r.ok);
|
||||
assert_eq!(r.to, "0.23.100");
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,110 @@ pub(super) fn opt_in_hint() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The Steam Deck source-rebuild leg (plan U3.1): run `~/punktfunk/scripts/steamdeck/update.sh
|
||||
/// --pull` in a TRANSIENT user unit — `systemd-run`, because the script ends by restarting
|
||||
/// `punktfunk-host.service`, and a child inside our own cgroup would be killed by that restart
|
||||
/// mid-run. No root involved (the Deck install is user-owned). Outcome plumbing:
|
||||
/// - build FAILS → the script exits without restarting us → the poll below sees the unit fail
|
||||
/// and reports it live, log attached;
|
||||
/// - build SUCCEEDS → the script restarts us → we die mid-poll; the `source_build` intent at
|
||||
/// next boot IS the success signal (`jobs::reconcile`).
|
||||
pub(super) fn run_apply_steamos(
|
||||
target_version: &str,
|
||||
serial: u64,
|
||||
stage: &dyn Fn(&'static str),
|
||||
) -> Result<(), (&'static str, String)> {
|
||||
let home = std::env::var("HOME").map_err(|_| ("applying", "no $HOME".to_string()))?;
|
||||
let script = std::path::Path::new(&home).join("punktfunk/scripts/steamdeck/update.sh");
|
||||
if !script.exists() {
|
||||
return Err((
|
||||
"applying",
|
||||
format!(
|
||||
"{} not found — is this the Deck on-device install?",
|
||||
script.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let log = pf_paths::config_dir()
|
||||
.join("logs")
|
||||
.join("update-steamos.log");
|
||||
if let Some(dir) = log.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
|
||||
jobs::write_json_atomic(
|
||||
&jobs::intent_path(),
|
||||
&IntentRecord {
|
||||
from: env!("PUNKTFUNK_VERSION").into(),
|
||||
to: target_version.into(),
|
||||
serial,
|
||||
started_unix: super::now_unix(),
|
||||
installer_sha256: String::new(),
|
||||
log_path: log.display().to_string(),
|
||||
source_build: true,
|
||||
},
|
||||
)
|
||||
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
|
||||
|
||||
const UNIT: &str = "pf-source-update";
|
||||
// `--collect` reaps the transient unit even on failure, so a retry can reuse the name.
|
||||
let launched = Command::new("systemd-run")
|
||||
.args(["--user", "--collect", "--unit", UNIT, "bash", "-c"])
|
||||
.arg(format!(
|
||||
"exec >> '{}' 2>&1; exec bash '{}' --pull",
|
||||
log.display(),
|
||||
script.display()
|
||||
))
|
||||
.status();
|
||||
match launched {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => {
|
||||
let _ = std::fs::remove_file(jobs::intent_path());
|
||||
return Err(("applying", format!("systemd-run exited {s}")));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(jobs::intent_path());
|
||||
return Err(("applying", format!("launch systemd-run: {e}")));
|
||||
}
|
||||
}
|
||||
stage("applying");
|
||||
|
||||
// Follow the transient unit. A successful build restarts this process before the unit
|
||||
// goes inactive, so leaving this loop alive means either "still building" or "failed".
|
||||
let deadline = Instant::now() + Duration::from_secs(90 * 60);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
let state = capture(Command::new("systemctl").args(["--user", "is-active", UNIT]))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "failed".into());
|
||||
match state.as_str() {
|
||||
"active" | "activating" | "deactivating" | "reloading" => {
|
||||
if Instant::now() > deadline {
|
||||
// Leave the build running (killing a half-linked build helps nobody) but
|
||||
// stop claiming it; the intent stays for reconcile if it ever finishes.
|
||||
return Err((
|
||||
"applying",
|
||||
format!(
|
||||
"the source rebuild is still running after 90 min — following it \
|
||||
ends here; see {}",
|
||||
log.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The unit ended and we are STILL ALIVE ⇒ the script never reached its restart
|
||||
// step ⇒ the build failed (an up-to-date tree still rebuilds+restarts).
|
||||
_ => {
|
||||
let _ = std::fs::remove_file(jobs::intent_path());
|
||||
return Err((
|
||||
"applying",
|
||||
format!("the source rebuild failed — see {}", log.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the whole Linux apply: start the oneshot, wait, interpret its result record, and for
|
||||
/// an in-place binary change, write the intent and restart ourselves (boot reconciliation
|
||||
/// reports the outcome). Blocking — run on a blocking thread.
|
||||
@@ -244,6 +348,7 @@ pub(super) fn run_apply(
|
||||
started_unix: super::now_unix(),
|
||||
installer_sha256: String::new(),
|
||||
log_path: "journalctl -u punktfunk-update.service".into(),
|
||||
source_build: false,
|
||||
},
|
||||
)
|
||||
.map_err(|e| ("restarting", format!("write intent record: {e}")))?;
|
||||
|
||||
@@ -95,6 +95,7 @@ pub(super) fn run_apply(
|
||||
started_unix: super::now_unix(),
|
||||
installer_sha256: asset.sha256.to_ascii_lowercase(),
|
||||
log_path: log.display().to_string(),
|
||||
source_build: false,
|
||||
},
|
||||
)
|
||||
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
|
||||
|
||||
@@ -72,6 +72,11 @@ requires `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the
|
||||
pacman update is a full `pacman -Syu` — partial upgrades are how Arch boxes break, and we
|
||||
won't run one. After a successful update the host restarts itself and the page reconnects.
|
||||
|
||||
The **Steam Deck on-device build** gets the button too, with no opt-in (it's your own user's
|
||||
install, no root involved): it runs the same `update.sh` rebuild the docs describe, which
|
||||
compiles on the Deck — expect it to take a while; the card keeps showing progress and the log
|
||||
lands in `~/.config/punktfunk/logs/update-steamos.log`.
|
||||
|
||||
## Turning the check off
|
||||
|
||||
The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a
|
||||
|
||||
@@ -396,7 +396,9 @@ const ApplyProgress: FC<{
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{timedOut && (
|
||||
{/* A live job (e.g. the Deck's tens-of-minutes source rebuild) is not "timed out" —
|
||||
the warning is for the host being GONE longer than a restart explains. */}
|
||||
{timedOut && !job && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
{m.update_apply_timeout()}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user