Skip to content

Commit c43ad07

Browse files
committed
Add update functionality and Docker API client integration
- Introduced the `bollard` crate for Docker API interactions, enabling self-update capabilities via the Docker socket. - Implemented update checking and applying mechanisms in the API, allowing for programmatic version checks and updates. - Enhanced the API server with new routes for update status and application, improving user experience for managing updates. - Updated the documentation to include instructions for manual and one-click updates, ensuring users are informed about the new features. - Added a background update checker to monitor for new releases, enhancing the system's responsiveness to updates.
1 parent cd58a83 commit c43ad07

File tree

13 files changed

+808
-42
lines changed

13 files changed

+808
-42
lines changed

.github/workflows/release.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ jobs:
5555
tags: |
5656
${{ env.IMAGE }}:${{ steps.version.outputs.version }}-slim
5757
${{ env.IMAGE }}:slim
58+
${{ env.IMAGE }}:latest
59+
cache-from: type=gha
60+
cache-to: type=gha,mode=max
61+
62+
- name: Build and push full
63+
uses: docker/build-push-action@v6
64+
with:
65+
context: .
66+
target: full
67+
push: true
68+
tags: |
69+
${{ env.IMAGE }}:${{ steps.version.outputs.version }}-full
70+
${{ env.IMAGE }}:full
5871
cache-from: type=gha
5972
cache-to: type=gha,mode=max
6073

Cargo.lock

Lines changed: 76 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ chromiumoxide_cdp = "0.8"
104104
# Templating for prompts
105105
minijinja = "2.8"
106106

107+
# Docker API client (for self-update via Docker socket)
108+
bollard = "0.18"
109+
110+
# Semver parsing (for update version comparison)
111+
semver = "1"
112+
107113
[lints.clippy]
108114
dbg_macro = "forbid"
109115
todo = "forbid"

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
5252
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
5353

5454
ENV SPACEBOT_DIR=/data
55+
ENV SPACEBOT_DEPLOYMENT=docker
5556
EXPOSE 19898 18789
5657

5758
VOLUME /data

docs/content/docs/(getting-started)/docker.mdx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,55 @@ healthcheck:
202202
- Graceful shutdown on `SIGTERM` (what `docker stop` sends). Drains active channels, closes database connections.
203203
- The PID file and Unix socket (used in daemon mode) are not created.
204204

205+
## Updates
206+
207+
Spacebot checks for new releases on startup and every hour. When a new version is available, a banner appears in the web UI.
208+
209+
### Manual Update
210+
211+
```bash
212+
docker pull ghcr.io/spacedriveapp/spacebot:slim
213+
docker compose up -d
214+
```
215+
216+
### One-Click Update
217+
218+
Mount the Docker socket to enable updating directly from the web UI:
219+
220+
```yaml
221+
services:
222+
spacebot:
223+
image: ghcr.io/spacedriveapp/spacebot:slim
224+
container_name: spacebot
225+
restart: unless-stopped
226+
ports:
227+
- "19898:19898"
228+
volumes:
229+
- spacebot-data:/data
230+
- /var/run/docker.sock:/var/run/docker.sock
231+
environment:
232+
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
233+
```
234+
235+
When the socket is mounted, the update banner shows an **Update now** button that pulls the new image and recreates the container automatically. Your `/data` volume is preserved across updates.
236+
237+
Without the socket mount, the banner still notifies you of new versions but you'll need to update manually.
238+
239+
### Update API
240+
241+
You can also check for and trigger updates programmatically:
242+
243+
```bash
244+
# Check for updates
245+
curl http://localhost:19898/api/update/check
246+
247+
# Force a fresh check
248+
curl -X POST http://localhost:19898/api/update/check
249+
250+
# Apply update (requires Docker socket)
251+
curl -X POST http://localhost:19898/api/update/apply
252+
```
253+
205254
## CI / Releases
206255

207256
Images are built and pushed to `ghcr.io/spacedriveapp/spacebot` via GitHub Actions (`.github/workflows/release.yml`).
@@ -217,12 +266,9 @@ Images are built and pushed to `ghcr.io/spacedriveapp/spacebot` via GitHub Actio
217266
|-----|-------------|
218267
| `v0.1.0-slim` | Versioned slim |
219268
| `v0.1.0-full` | Versioned full |
220-
| `v0.1.0` | Versioned (points to full) |
221269
| `slim` | Rolling slim |
222270
| `full` | Rolling full |
223-
| `latest` | Rolling (points to full) |
224-
225-
The `latest` tag always points to the `full` variant.
271+
| `latest` | Rolling (points to slim) |
226272

227273
## Fly.io Deployment
228274

interface/src/api/client.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const API_BASE = "/api";
22

33
export interface StatusResponse {
44
status: string;
5+
version: string;
56
pid: number;
67
uptime_seconds: number;
78
}
@@ -236,11 +237,31 @@ export interface AgentSummary {
236237
}
237238

238239
export interface InstanceOverviewResponse {
240+
version: string;
239241
uptime_seconds: number;
240242
pid: number;
241243
agents: AgentSummary[];
242244
}
243245

246+
export type Deployment = "docker" | "native";
247+
248+
export interface UpdateStatus {
249+
current_version: string;
250+
latest_version: string | null;
251+
update_available: boolean;
252+
release_url: string | null;
253+
release_notes: string | null;
254+
deployment: Deployment;
255+
can_apply: boolean;
256+
checked_at: string | null;
257+
error: string | null;
258+
}
259+
260+
export interface UpdateApplyResponse {
261+
status: "updating" | "error";
262+
error?: string;
263+
}
264+
244265
export type MemoryType =
245266
| "fact"
246267
| "preference"
@@ -955,5 +976,22 @@ export const api = {
955976
return response.json() as Promise<DeleteBindingResponse>;
956977
},
957978

979+
// Update API
980+
updateCheck: () => fetchJson<UpdateStatus>("/update/check"),
981+
updateCheckNow: async () => {
982+
const response = await fetch(`${API_BASE}/update/check`, { method: "POST" });
983+
if (!response.ok) {
984+
throw new Error(`API error: ${response.status}`);
985+
}
986+
return response.json() as Promise<UpdateStatus>;
987+
},
988+
updateApply: async () => {
989+
const response = await fetch(`${API_BASE}/update/apply`, { method: "POST" });
990+
if (!response.ok) {
991+
throw new Error(`API error: ${response.status}`);
992+
}
993+
return response.json() as Promise<UpdateApplyResponse>;
994+
},
995+
958996
eventsUrl: `${API_BASE}/events`,
959997
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {useState} from "react";
2+
import {useQuery, useMutation} from "@tanstack/react-query";
3+
import {api} from "@/api/client";
4+
5+
export function UpdateBanner() {
6+
const [dismissed, setDismissed] = useState(false);
7+
8+
const {data} = useQuery({
9+
queryKey: ["updateCheck"],
10+
queryFn: api.updateCheck,
11+
staleTime: 60_000,
12+
refetchInterval: 300_000,
13+
});
14+
15+
const applyMutation = useMutation({
16+
mutationFn: api.updateApply,
17+
onSuccess: (result) => {
18+
if (result.status === "error") {
19+
setApplyError(result.error ?? "Update failed");
20+
}
21+
},
22+
});
23+
24+
const [applyError, setApplyError] = useState<string | null>(null);
25+
26+
if (!data || !data.update_available || dismissed) return null;
27+
28+
const isApplying = applyMutation.isPending;
29+
30+
return (
31+
<div className="border-b border-cyan-500/20 bg-cyan-500/10 px-4 py-2 text-sm text-cyan-400">
32+
<div className="flex items-center justify-between">
33+
<div className="flex items-center gap-2">
34+
<div className="h-1.5 w-1.5 rounded-full bg-current" />
35+
<span>
36+
Version <strong>{data.latest_version}</strong> is available
37+
<span className="text-ink-faint ml-1">(current: {data.current_version})</span>
38+
</span>
39+
{data.release_url && (
40+
<a
41+
href={data.release_url}
42+
target="_blank"
43+
rel="noopener noreferrer"
44+
className="underline hover:text-cyan-300"
45+
>
46+
Release notes
47+
</a>
48+
)}
49+
</div>
50+
<div className="flex items-center gap-2">
51+
{data.can_apply && (
52+
<button
53+
onClick={() => {
54+
setApplyError(null);
55+
applyMutation.mutate();
56+
}}
57+
disabled={isApplying}
58+
className="rounded bg-cyan-500/20 px-2.5 py-1 text-xs font-medium text-cyan-300 hover:bg-cyan-500/30 disabled:opacity-50"
59+
>
60+
{isApplying ? "Updating..." : "Update now"}
61+
</button>
62+
)}
63+
{!data.can_apply && data.deployment === "docker" && (
64+
<span className="text-xs text-ink-faint">
65+
Mount docker.sock for one-click updates
66+
</span>
67+
)}
68+
<button
69+
onClick={() => setDismissed(true)}
70+
className="text-ink-faint hover:text-ink ml-1"
71+
>
72+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
73+
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
74+
</svg>
75+
</button>
76+
</div>
77+
</div>
78+
{applyError && (
79+
<div className="mt-1 text-xs text-red-400">{applyError}</div>
80+
)}
81+
</div>
82+
);
83+
}

0 commit comments

Comments
 (0)