Skip to content

Commit aaa4e9a

Browse files
committed
feat(panel): add per-bot image updates and panel self-update
- Add strict image pulls (`require_fresh_image`) for panel self-update so GHCR outages cannot arm a swap onto stale cached tags - Add `local_image_digests` to compare running containers against registry digests without pulling on every status poll - Add `schedule_image_swap` to arm panel self-update by creating a replacement container and spawning a short-lived Docker CLI helper to finish the swap after the panel process exits - Resolve docker socket paths through container mounts in the helper bind so custom socket paths (not just `/var/run/docker.sock`) work correctly - Add Update button for eligible bots that pulls `STITCH_PANEL_BOT_IMAGE` (resolving pinned sha-* tags to `:latest`) and recreates that bot only, preserving config and keys - Restrict Update to bots on the configured image channel and migrated to per-bot directory layout (flat layout would lose the in-container nonce ledger on recreate) - Add `switch_corridor` to replace stitch.toml with a new corridor preset while keeping the signer, stopping the bot so tokens can be re-approved - Add `/api/updates` endpoint that checks registry digests (cached ~15 minutes) for newer bot and panel images, with `?refresh=1` to force recheck - Expand `SettingsForm` desktop field set to corridor, signer, spreads, taker, endpoints and simplify mobile form - Add Update panel button in header and per-bot Update / Recreate buttons on bot detail pages - Expand `install-panel.md` with image update workflow, self-update behavior, and hardening notes on pinning and ACLs
1 parent e27428e commit aaa4e9a

22 files changed

Lines changed: 2244 additions & 268 deletions

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
caf6045aec73fef8ea0875b561562a8fe5ff10df
1+
4e22252d80b6447d234bc7018eddc6198f960dd0

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.109
1+
0.1.110

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.109"
3+
version = "0.1.110"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; market-makes the filler order book with signed UniswapX limit orders."
66
license = "AGPL-3.0-or-later"

docs/install-panel.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,8 @@ it; the compose export is there for when you want to go back.
331331
| Permit2 approve | `docker compose run --rm bot1 stitch approve …` | Approve allowances, output streamed |
332332
| Dry run | same, with `--dry-run` | Dry run button |
333333
| Logs | `docker compose logs -f bot1` | live tail with level colouring |
334-
| Upgrade a bot | edit the image tag, `up -d` | Recreate, on the panel's configured image |
334+
| Upgrade a bot | edit the image tag, `up -d` | **Update** when a newer digest is available (pulls `STITCH_PANEL_BOT_IMAGE` and recreates that bot). **Recreate** still rebuilds on the configured image for recovery. |
335+
| Upgrade the panel | `docker compose pull && up -d` | **Update panel** in the header when a newer `textile-stitch-panel` digest is published |
335336

336337
**Approve needs the operator wallet to itself.** It runs in a throwaway container
337338
with its own copy of the key, and it broadcasts. So does a bot's taker or closer
@@ -497,14 +498,35 @@ Check you're browsing the `ts.net` URL rather than an IP, that your device isn't
497498
tagged, and that your login is spelled exactly as it appears in the Tailscale
498499
console.
499500

501+
## Image updates
502+
503+
The panel checks the registry (GHCR) for newer digests of the configured bot
504+
image and of its own panel image. Results are cached for about 15 minutes; pass
505+
`?refresh=1` on `/api/updates` (or use the UI after an update) to force a recheck.
506+
507+
- **Per-bot Update** pulls `STITCH_PANEL_BOT_IMAGE` and recreates that bot only.
508+
Config and key stay on disk. Expect a brief gap in quoting. If the bot still
509+
uses the flat layout, migrate first so the nonce ledger isn't lost on recreate.
510+
- **Recreate** is the same Docker action without the "you're behind" nudge — use
511+
it for recovery (missing container, stuck state).
512+
- **Update panel** pulls a newer `textile-stitch-panel` image (pinned `sha-*`
513+
tags resolve to `:latest` of the same repo) and schedules a self-recreate via a
514+
short-lived helper on the Docker socket. The UI disconnects briefly; bots keep
515+
running. Local-only images (`stitch-panel` with no registry path) can't
516+
self-update — rebuild or set `PANEL_IMAGE` to the published GHCR image.
517+
518+
Offline or private registries that reject anonymous pulls soft-fail: the UI
519+
simply doesn't show an update, rather than erroring the fleet page.
520+
500521
## Hardening, once it works
501522

502523
- **Tag the panel node.** Define a tag in your tailnet policy file, then set
503524
`TS_EXTRA_ARGS=--advertise-tags=tag:stitch-panel` in `.env` and recreate the
504525
sidecar. Tagged nodes don't expire keys, and ACLs can restrict who reaches the
505526
panel at the network level as well as at the allowlist.
506527
- **Pin the bot image.** `STITCH_PANEL_BOT_IMAGE` to a `sha-*` tag, so a restart
507-
can't change the bot binary under you.
528+
can't change the bot binary under you. The Update button still offers a move to
529+
a newer publish when one appears.
508530
- **Restrict at the ACL level too.** The allowlist is the panel's own check;
509531
a tailnet ACL means an unlisted device can't even open a connection.
510532
- **Don't use Funnel.** It strips identity headers and publishes the panel to the

src/panel/docker/bollard_api.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ impl DockerApi for BollardDocker {
144144
// that same anonymous pull. Failing here would strand a bot on an image
145145
// that is right there on the host, so warn and use it. With nothing local
146146
// there is nothing to fall back to, so the pull error stands.
147+
//
148+
// Panel self-update must NOT use this fallback — see
149+
// [`DockerApi::require_fresh_image`].
147150
Err(e) if present => {
148151
tracing::warn!(
149152
"couldn't refresh {image}, using the copy already on the host: {e:#}"
@@ -154,6 +157,138 @@ impl DockerApi for BollardDocker {
154157
}
155158
}
156159

160+
async fn require_fresh_image(&self, image: &str) -> Result<()> {
161+
self.pull_image(image).await.with_context(|| {
162+
format!("pulling {image} — refusing to continue on a possibly stale local copy")
163+
})
164+
}
165+
166+
async fn local_image_digests(&self, image: &str) -> Result<Vec<String>> {
167+
match self.docker.inspect_image(image).await {
168+
Ok(info) => Ok(info.repo_digests.unwrap_or_default()),
169+
// Not on the host yet — an empty list means "nothing to match against",
170+
// which the update check treats as unknown rather than "behind".
171+
Err(_) => Ok(Vec::new()),
172+
}
173+
}
174+
175+
async fn schedule_image_swap(
176+
&self,
177+
name: &str,
178+
new_image: &str,
179+
docker_socket: &Path,
180+
) -> Result<()> {
181+
// Strict pull — never fall back to a cached local tag. ensure_image's
182+
// refresh path tolerates pull failure when a local copy exists (bot
183+
// Recreate), but self-update must not restart the panel onto a stale
184+
// `:latest` after GHCR/auth/rate-limit failures.
185+
self.require_fresh_image(new_image).await?;
186+
187+
let inspect = self
188+
.docker
189+
.inspect_container(name, None)
190+
.await
191+
.with_context(|| format!("inspecting {name} for a self-update"))?;
192+
let config = inspect
193+
.config
194+
.context("the container has no Config — cannot clone it for an update")?;
195+
let host_config = inspect.host_config.unwrap_or_default();
196+
197+
let next = format!("{name}-next");
198+
// A leftover from a previous failed swap must not block this one.
199+
let _ = self
200+
.docker
201+
.remove_container(
202+
&next,
203+
Some(RemoveContainerOptionsBuilder::default().force(true).build()),
204+
)
205+
.await;
206+
207+
let body = ContainerCreateBody {
208+
image: Some(new_image.to_string()),
209+
env: config.env.clone(),
210+
cmd: config.cmd.clone(),
211+
entrypoint: config.entrypoint.clone(),
212+
working_dir: config.working_dir.clone(),
213+
user: config.user.clone(),
214+
labels: config.labels.clone(),
215+
// HostConfig.port_bindings alone is not enough — Docker only publishes
216+
// a host port when the create Config also exposes the container port.
217+
// The password-only install snippet binds 127.0.0.1:8420:8420; dropping
218+
// exposed_ports here would leave the UI unreachable after self-update.
219+
exposed_ports: config.exposed_ports.clone(),
220+
host_config: Some(host_config),
221+
stop_timeout: config.stop_timeout.or(Some(STOP_GRACE_SECS)),
222+
..Default::default()
223+
};
224+
let options = CreateContainerOptionsBuilder::default().name(&next).build();
225+
self.docker
226+
.create_container(Some(options), body)
227+
.await
228+
.with_context(|| format!("creating the replacement container {next}"))?;
229+
230+
// The panel process lives inside `name`. Stopping it from here would kill
231+
// the task mid-swap, so a short-lived helper on the docker socket finishes
232+
// the rename after we return. CreateContainer bind sources are host paths:
233+
// resolve STITCH_PANEL_DOCKER_SOCKET (in-container) through this container's
234+
// mounts to the host source, then mount that onto the helper's default path.
235+
let mounts: Vec<crate::panel::docker::MountInfo> = inspect
236+
.mounts
237+
.as_ref()
238+
.map(|m| m.iter().map(to_mount_info).collect())
239+
.unwrap_or_default();
240+
let host_socket = crate::panel::docker::host_docker_socket_bind(&mounts, docker_socket);
241+
let socket = host_socket
242+
.to_str()
243+
.ok_or_else(|| anyhow::anyhow!("docker socket path is not valid UTF-8"))?;
244+
anyhow::ensure!(
245+
!socket.contains(':'),
246+
"docker socket path {socket} contains a colon, which Docker cannot express in a bind mount"
247+
);
248+
const HELPER_IMAGE: &str = "docker:27-cli";
249+
self.ensure_image(HELPER_IMAGE, false).await?;
250+
let script = format!(
251+
"sleep 2 && docker stop -t 30 {name} && docker rm -f {name} && \
252+
docker rename {next} {name} && docker start {name}"
253+
);
254+
let helper_name = format!("{name}-updater");
255+
let _ = self
256+
.docker
257+
.remove_container(
258+
&helper_name,
259+
Some(RemoveContainerOptionsBuilder::default().force(true).build()),
260+
)
261+
.await;
262+
let helper_body = ContainerCreateBody {
263+
image: Some(HELPER_IMAGE.into()),
264+
cmd: Some(vec!["sh".into(), "-c".into(), script]),
265+
host_config: Some(HostConfig {
266+
binds: Some(vec![format!("{socket}:/var/run/docker.sock")]),
267+
auto_remove: Some(true),
268+
..Default::default()
269+
}),
270+
..Default::default()
271+
};
272+
let helper_opts = CreateContainerOptionsBuilder::default()
273+
.name(&helper_name)
274+
.build();
275+
let helper = self
276+
.docker
277+
.create_container(Some(helper_opts), helper_body)
278+
.await
279+
.context("creating the panel self-update helper")?;
280+
self.docker
281+
.start_container(&helper.id, None)
282+
.await
283+
.context("starting the panel self-update helper")?;
284+
tracing::info!(
285+
container = %name,
286+
new_image,
287+
"armed panel image swap; helper will recreate the container shortly"
288+
);
289+
Ok(())
290+
}
291+
157292
async fn create(&self, spec: &CreateSpec) -> Result<String> {
158293
let binds = spec
159294
.binds
@@ -632,6 +767,7 @@ fn to_container_info(s: &ContainerSummary) -> ContainerInfo {
632767
.map(|n| n.trim_start_matches('/').to_string())
633768
.unwrap_or_default(),
634769
image: s.image.clone().unwrap_or_default(),
770+
image_id: s.image_id.clone().unwrap_or_default(),
635771
state: s
636772
.state
637773
.as_ref()

src/panel/docker/fake.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ pub enum Call {
3131
image: String,
3232
refresh: bool,
3333
},
34+
LocalImageDigests(String),
35+
ScheduleImageSwap {
36+
name: String,
37+
new_image: String,
38+
docker_socket: String,
39+
},
3440
Create(String),
3541
Start(String),
3642
Stop {
@@ -115,6 +121,10 @@ struct FakeState {
115121
/// a handler that re-reads the fleet after acting needs its first read to succeed
116122
/// and a later one to fail. `None` never fails.
117123
list_calls_left: Option<usize>,
124+
/// Digests `local_image_digests` returns for a given image reference.
125+
image_digests: HashMap<String, Vec<String>>,
126+
/// When set, `schedule_image_swap` fails with this message.
127+
swap_error: Option<String>,
118128
}
119129

120130
impl FakeDocker {
@@ -183,6 +193,20 @@ impl FakeDocker {
183193
self.state.lock().unwrap().image_error = Some(message.to_string());
184194
}
185195

196+
/// Seed the digests a local image reports, for update-detection tests.
197+
pub fn set_image_digests(&self, image: &str, digests: Vec<String>) {
198+
self.state
199+
.lock()
200+
.unwrap()
201+
.image_digests
202+
.insert(image.to_string(), digests);
203+
}
204+
205+
/// Make `schedule_image_swap` fail.
206+
pub fn fail_swap(&self, message: &str) {
207+
self.state.lock().unwrap().swap_error = Some(message.to_string());
208+
}
209+
186210
/// Let the next `allowed` calls to `list_all` succeed, then fail every one after.
187211
/// Stands in for the daemon going unreachable partway through a handler.
188212
pub fn fail_list_after(&self, allowed: usize) {
@@ -271,6 +295,51 @@ impl DockerApi for FakeDocker {
271295
}
272296
}
273297

298+
async fn require_fresh_image(&self, image: &str) -> Result<()> {
299+
// Same failure surface as a hard pull — the fake has no local-fallback
300+
// path to model separately.
301+
self.ensure_image(image, true).await
302+
}
303+
304+
async fn local_image_digests(&self, image: &str) -> Result<Vec<String>> {
305+
let mut st = self.state.lock().unwrap();
306+
st.calls.push(Call::LocalImageDigests(image.to_string()));
307+
Ok(st.image_digests.get(image).cloned().unwrap_or_default())
308+
}
309+
310+
async fn schedule_image_swap(
311+
&self,
312+
name: &str,
313+
new_image: &str,
314+
docker_socket: &std::path::Path,
315+
) -> Result<()> {
316+
// Mirror production: strict pull before arming the swap.
317+
self.require_fresh_image(new_image).await?;
318+
let mut st = self.state.lock().unwrap();
319+
let host_socket = st
320+
.containers
321+
.iter()
322+
.find(|c| c.name == name)
323+
.map(|c| crate::panel::docker::host_docker_socket_bind(&c.mounts, docker_socket))
324+
.unwrap_or_else(|| docker_socket.to_path_buf());
325+
st.calls.push(Call::ScheduleImageSwap {
326+
name: name.to_string(),
327+
new_image: new_image.to_string(),
328+
docker_socket: host_socket.display().to_string(),
329+
});
330+
if let Some(msg) = st.swap_error.clone() {
331+
bail!(msg);
332+
}
333+
// Stand in for a successful swap: the container is now on the new image.
334+
if let Some(c) = st.containers.iter_mut().find(|c| c.name == name) {
335+
c.image = new_image.to_string();
336+
c.image_id = format!("sha256:swapped-{new_image}");
337+
} else {
338+
bail!("No such container: {name}");
339+
}
340+
Ok(())
341+
}
342+
274343
async fn create(&self, spec: &CreateSpec) -> Result<String> {
275344
let mut st = self.state.lock().unwrap();
276345
Self::check_failure(&mut st)?;
@@ -289,6 +358,7 @@ impl DockerApi for FakeDocker {
289358
id: id.clone(),
290359
name: spec.name.clone(),
291360
image: spec.image.clone(),
361+
image_id: format!("sha256:fake-{}", spec.name),
292362
state: ContainerState::Created,
293363
status: "Created".to_string(),
294364
created_unix: 0,
@@ -442,6 +512,7 @@ pub fn container(name: &str, state: ContainerState) -> ContainerInfo {
442512
id: format!("id-{name}"),
443513
name: name.to_string(),
444514
image: "ghcr.io/textile-protocol/textile-stitch:latest".to_string(),
515+
image_id: format!("sha256:id-{name}"),
445516
state,
446517
status: state.as_str().to_string(),
447518
created_unix: 1_700_000_000,

0 commit comments

Comments
 (0)