main
67
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ba788e8b03 |
Link port chips to the Docker host, not the proxy's domain (0.67.3)
Ports published on 0.0.0.0 were linked at window.location.hostname. Opened
through a reverse proxy such as Nginx Proxy Manager that is the proxy's
domain, and domain:8080 ended up at the proxy instead of the container.
The backend now reads the host's address from the host's routing table
(${HOST_PROC_PATH}/1/net/route + fib_trie, the /proc mount the compose file
already has): the IPv4 address on the interface with the best default
route. There is no fallback to /proc/net, which in a bridged container is
the container's own namespace with an unreachable 172.x address.
The links use, in order: the new "Host address for port links" setting
(Settings -> General), the page's hostname when it is an IP or localhost,
the detected address, and the page's hostname as a last resort. Ports
bound to one specific address still link there; IPv6 hosts are bracketed.
The setting is validated before anything is written, so a bad value no
longer leaves the interval half-saved in the same request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
06234c1067 |
Run compose like the terminal does, and follow changes made there (0.67.2)
StackPilot called `docker compose -p <folder> -f compose.yaml`, which is
not what `docker compose` typed in the stack's folder does. A
compose.override.yaml or a COMPOSE_FILE in .env applied in a terminal
and was dropped by the next Start in StackPilot, which recreated the
containers without it. A compose file's `name:` or a COMPOSE_PROJECT_NAME
in .env made the terminal's containers invisible: the stack showed as
stopped while running, and Start set up a second copy next to it.
Compose now runs in the stack's folder without -f, so it loads the same
files, and -p is the name compose itself picks there: COMPOSE_PROJECT_NAME
from .env, else the last `name:` among the compose files (${VAR} from
.env), else the folder name. A stack StackPilot already runs under its
folder name keeps that name while it has containers or volumes under it
and none under the declared one: switching would have brought it up
again on empty volumes. Backups record the project, and a restore takes
it from the files it has just put back.
Changes made outside StackPilot now reach every view:
- docker_watch_service reads Docker's events once for the backend. The
dashboard rollup (cached 25s) and the stats cache are stale as soon as
the event generation moves; the tiles followed a terminal `compose
stop` after 15-30s, now within a second.
- Image events re-read the local digests, so a `docker compose pull` in
a terminal clears the update badge instead of leaving it until the
next registry check (an hour by default).
- The browser catches up when its event socket reconnects; what changed
while it was down waited for the 60s poll.
- The stack detail carries a revision of the compose file and .env;
saving after they changed on disk is refused with 409 and the editor
offers to overwrite or to load the version on disk. A save used to
replace a terminal edit without a word.
- A stack whose folder is gone is flagged "folder missing" instead of
passing for an ordinary stopped stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
|
||
|
|
d6cebb3371 |
Fix what a review of the whole app found, end to end (0.67.1)
A pass over every feature against a real Docker daemon, then clicking through the UI as admin and read-only user, on a desktop and a phone. Security. /ws/deploy ran compose up for the read-only role; it is admin-only now. A container's inspect view, a stack's compose file (POSTGRES_PASSWORD: hunter2) and the viewer's .env tab no longer show credentials to that role. Git branch and URL values that git would read as options (-x, ext::) are refused. SFTP host keys are checked: pinned on first use or entered by hand (key line or SHA256 fingerprint), a different key is refused before the password is sent. A backup name from the API can no longer reach outside the destination folder. The file browser refuses to move or rename a browse root, as delete did. Docker lifecycle. Closing the deploy console released the stack lock while compose was still running and logged rc=None as success; deploys and updates are now background runs that hold the lock to the end, report the real exit code and can be re-attached to. Every closed log view left a `compose logs -f` behind. A dozen open tabs exhausted the default thread pool and hung the API; event and exec streams have their own threads. Auto-update and the update endpoint ignored failed pulls. A deleted git stack was recreated and started by the next webhook or poll; deleting a stack now drops its git source, auto-update policy and schedule entries. Deleting no longer walks into mounted shares, is refused while a backup runs, and removes the containers even when the compose file is broken. Stack folders compose has to normalise (Nextcloud -> nextcloud) work everywhere and group correctly on the volumes, networks and images pages. Detection. Host port conflicts read the container's own network namespace; /proc/1/net is read instead. Images referenced as docker.io/… were never update-checked; local builds no longer report "needs credentials". NVIDIA GPUs are read from the driver's procfs. The backup dialog's "path mismatch" warning came from mountinfo's filesystem-relative root and fired for any stacks folder on its own disk, ZFS dataset or btrfs subvolume; the host path now comes from Docker's mounts. Backups and destinations. The download ignored the folder/volume selection (axios sent binds[]). Restoring under a new name kept the old one. A report header past nginx's buffer turned a good backup into a 502. SFTP/S3 errors were bare 500s and "Test" did not try to write. A destination used by a schedule could be deleted. Backup file names are in the schedule's or the browser's time zone instead of UTC. Editor and converter. `docker run` conversion produced invalid compose for --network host, named volumes and -dit; --gpus and --mount were dropped; a multi-line command pasted into the one-line field became `image: ' '` (now a textarea, and flattened continuations are read). Validation failed for env_file stacks. The editor overwrote unsaved edits on every container event, lost them on a stray click (now asks), threw when closing the diff, and its helper panel listed the starter service "app" for every existing stack. Monaco is bundled instead of loaded from jsdelivr, so the editor works offline (and monaco-editor 0.57 drops the dompurify advisory; axios and react-router updated). UI. All times were two hours off: offset-less UTC from SQLite was read as local time, and "next run" always said "just now". A token refresh mid-session duplicated deploys, reset terminals and doubled logs. A backend restart (update, self-update) signed everyone out on reload; the page now waits for it. The editor was unusable on a phone, tables and tabs overflowed, dialog text was black on the dark theme, and the file browser opened on an error with the default roots. Read-only users get "Admin only" pages instead of 403s; in-use networks offer no delete; "Scan all" covers StackPilot's own stacks only and runs in the background instead of timing out at 600s. Deployment. nginx passes the port in Host (the OIDC redirect URI lost :5009) and keeps an outer proxy's X-Forwarded-Proto. The frontend waits for a healthy backend (healthcheck probes every 2s while starting). The frontend image builds with npm ci. Secrets are 0644 in a 0700 folder so non-root containers can read them; existing files are fixed at startup. The test suite no longer starts real containers on a host with Docker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu |
||
|
|
270ddda51d |
Let a stuck backup be stopped instead of stranding the stack (0.67.0)
A backup holds the stack lock for up to twelve hours — a large volume
legitimately takes that long — and stops the stack's containers for a
consistent snapshot. One that died without unwinding therefore left the
stack stopped and refusing every start, stop, restart and deploy with
"Stack 'npmplus' is busy: backup in progress", with nothing to click
until the TTL ran out.
Backups and restores now register themselves in stack_job_service while
they run, with their phase and the helper containers they stream
through. Cancelling sets a flag and removes those containers, which is
what actually ends the long read: the backup then unwinds through its
own finally — the half-written archive goes, the containers it stopped
are started again, the lock is released. Checks sit between two files
and two volumes, never inside one. A restore is not cancellable at all;
it is halfway through writing a stack's data back.
POST /api/stacks/{id}/unlock does that and waits for it. A backup lock
with no job behind it is a leftover, so it is released on one click; a
deploy lock takes an explicit force, because compose keeps running past
its request. GET /api/stacks/{id}/lock and the stack detail response say
what holds the stack and since when, and the stack page turns that into
"Stop backup" / "Release lock".
Startup now clears every lock rather than only expired ones: after a
restart nothing of ours is in flight, the same reasoning the leftover
helper containers already follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
052a85067b |
Stop handing StackPilot's own settings to the stacks it deploys (0.66.3)
docker compose ran with StackPilot's whole environment, and compose
resolves ${VAR} from the calling environment before a stack's own .env.
A stack whose compose file says SECRET_KEY: ${SECRET_KEY} therefore ran
with StackPilot's SECRET_KEY instead of the one in its .env — and with
its own again whenever it was started from a shell. AnsiblePilot hit
exactly this: credentials saved under one key became unreadable under
the other, twice.
config.child_env() is os.environ minus every StackPilot setting
(SECRET_KEY, DATA_DIR, STACKS_DIR, PORT, ...); the docker CLI and git
subprocesses now get that instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
34458c702c |
Mount NFS destinations with nolock so they need no nfs-common (0.66.2)
An NFSv3 mount with locking makes the kernel register its lock manager with rpcbind on the Docker host itself. On a host without nfs-common the mount fails with a bare "connection refused" that reads like the NAS is unreachable, even though the NAS and its ports are fine. Only StackPilot ever writes the backup files on an NFS destination, one writer per file, so nothing needs locking. mount_options() now adds nolock unless the options already choose (lock, nolock, local_lock=) or ask for NFSv4, which locks in-protocol and needs no rpcbind. Existing destinations get their volume recreated once with the new options. The refused-mount hint now points at rpcbind/nfs-common on the Docker host first, and "protocol not supported" explains the missing kernel module (modprobe nfsv4). The form labels the option. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c62803dc7 |
Stop leaking helper containers that fail to start (0.66.1)
docker-py's containers.run(remove=True) only removes a container after it ran. A helper whose start failed - an NFS destination refusing the mount - stayed behind in "Created", holding the destination's volume. Every Test click and scheduled backup added one (a user found 18), the volume could not be deleted even with everything stopped, and changed mount options never took effect because the volume could not be recreated with them. - docker_client.run_container: create, start, wait, collect output and always remove, also when the start fails; pulls a missing image. Used for NFS destination I/O, bind-path inspection, the volume wipe before a restore and the Trivy scanner. - Every helper container is labelled stackpilot.helper; on startup the stopped ones are removed, together with anything left on an NFS destination volume by older versions. - When an NFS destination's options change, stopped containers holding its volume are removed before it is recreated; a running one is now a clear error instead of silently keeping the old options. - A refused mount explains itself: NFS enabled and reachable from the Docker host, NFSv3 needing rpcbind and mountd, and nfsvers=4 needing only port 2049. - The scanner waits for Trivy's own 10-minute timeout instead of giving up after the default client's 60 seconds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9a357d2b92 |
Schedule backups in local time instead of UTC (0.66.0)
Schedule times were UTC, so a nightly 03:00 backup had to be entered as 01:00 or 02:00 depending on the season, and moved by an hour twice a year. Each schedule now has an IANA time zone, preset in the form to the browser's (e.g. Europe/Berlin), and its hour/minute/weekday are wall-clock time there. compute_next_run builds each candidate day from its date in that zone rather than adding 24 hours, so the time stays put across daylight saving changes; a time skipped by the spring change runs an hour later that day. next_run is still stored as naive UTC. - Existing schedules default to UTC and keep their run times until edited. - The API validates the zone and hour/minute/weekday ranges (400) instead of silently wrapping them. - tzdata is a dependency: the slim base image has no system tz database. Tests cover Berlin winter and summer time, both 2026 change days, the skipped hour, a weekly run across the UTC date line, hourly at a +5:30 offset, the UTC fallback and the API checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a0d673798 |
Back up several stacks, or all of them, from one schedule (0.65.0)
A schedule covered exactly one stack, so backing up a host meant one
schedule per stack, each edited separately, and new stacks were only
backed up once someone remembered to add a schedule.
A schedule now takes a list of stacks, or all_stacks, which includes
stacks added later. They are backed up one after another, each with its
own file, upload, retention and status, so one failing stack does not
cost the others their backup:
- stack_status records each stack's result; last_status summarises
("error: 2 of 5 stacks - a: ...; c: ...") and one notification per run
names the stacks with a problem. The dashboard names them too and shows
an incomplete backup as a warning rather than an error.
- A busy stack (deploy, update) is retried after the others, up to three
times two minutes apart, replacing the 10-minute postponement of the
whole schedule.
- Run now starts the run in the background (202) instead of holding the
request, which a multi-stack run would outlast; the list reports
running and polls, and the row toasts the outcome. A schedule never
runs twice at once.
- Settings: "All stacks" or a checklist with select all/none; stacks can
be changed when editing; deleted stacks stay listed to be unticked.
Schedules from before keep working without a data migration: stack_ids,
all_stacks and stack_status are added with defaults on startup and a row
with only stack_id reads as that one stack. The API still accepts
stack_id and reports it for single-stack schedules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8f0a01c4f5 |
Make backup and restore reliable, and keep NFS out of them (0.64.0)
A review of the backup path found several ways a backup was not one, and a restore could destroy data it did not contain. Data loss: - Restore with overwrite rmtree'd the whole stack directory, including folders the backup had left out (over 2 GiB, deselected, an NFS share mounted inside). It now clears only what the backup holds, keeps the excluded paths (recorded in the manifest) and never crosses into another filesystem. - The stack was only stopped when named volumes were selected, so data folders inside the stack directory were archived while in use. Any selected data now stops it. - Retention matched by prefix: pruning "app" deleted "app-db"'s backups. It now matches backup-<id>-(full|config)-<date>.tar.gz exactly. - A restore under a new name wrote into the original's volumes when they had an explicit name, and into its folders outside the stack directory. Both are skipped now; a volume owned by another stack or mounted by a running container is never overwritten. - A truncated archive was only noticed after the old stack was gone. The whole archive is verified first, and the stack directory is unpacked next to the old one and swapped in. Remote storage: - Bind folders on a mounted share (fstab/autofs), bind-type volumes pointing at one and shares mounted inside the stack folder are detected by filesystem type and not selected; a folder du cannot measure in 20s is no longer selected by default either. Reliability: - After a backup exactly the containers that ran are started again with compose start (falling back to the Docker API), instead of up -d, which started services stopped on purpose and applied undeployed edits. A stack that cannot be stopped is not backed up. - Backup and restore hold the stack lock; a scheduled backup that finds the stack busy retries after 10 minutes. Restore stops the stack while replacing its data and brings it back up. - external: true volumes are backed up; anonymous ones are listed, not selected. Restored volumes get their original driver and options. - A backup with uncaptured data or a stack that did not restart is reported as incomplete: in the dialog, and for schedules as a warning with a notification and without rotating older backups out. - Archive writing and restore run off the event loop; downloads delete their temp file; helper containers use the long-timeout client; S3 listing is paginated; a file changing mid-read no longer corrupts the archive. Manifest format 3 records compose_binds/excluded and volume driver/options/external; format 1/2 backups still restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ee98d0da6d |
Show live progress while pruning networks, and ask first (0.63.0)
The network prune joins the image and volume prunes on the shared
PruneJob: it runs in the background, the Networks page shows a progress
bar and a toast with the outcome, and the button now asks before deleting
instead of pruning on click. Docker has no -a for networks and they hold
no data, so there is no option and no space figure.
Candidates are stricter than docker network prune, which goes by active
endpoints and so removes a stopped container's network, after which that
container fails to start ("network not found"). A network any container,
running or stopped, is attached to (by name or id) is kept, as are
bridge/host/none, ingress/docker_gwbridge and swarm-scoped networks.
Removal is per network, so one the daemon refuses does not stop the rest.
- POST /api/networks/prune takes background=; without it it still waits
and returns {NetworksDeleted}. GET /api/networks/prune-status is
admin-only and registered before /{network_id}, which would otherwise
take "prune-status" for an id.
- The long-timeout test for network prune in test_docker_timeouts.py is
superseded by test_network_prune.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
57231e4a67 |
Show live progress while pruning volumes, and ask first (0.62.0)
The volume prune gets the same treatment as the image prune in 0.61.0:
the candidates are listed and removed one by one in the background, and
the Volumes page shows a progress bar (x / y, the volume being removed,
failures) followed by a toast with the space freed.
The button also used to delete without asking, and a bare docker volume
prune only removes anonymous volumes on Engine 23+ with no way to include
named ones. It now opens a confirmation dialog with an explicit -a option
that warns it also takes the data of every stack that is down.
- Candidates: volumes no container (running or stopped) mounts, local
scope only, and anonymous (com.docker.volume.anonymous) unless -a.
Volumes from pre-23 daemons carry no such label, so they only go with
-a, which errs on the side of keeping data.
- Removed without force, so a volume mounted since the listing is
refused; one refusal does not stop the rest.
- No closing daemon prune: nothing is left over, and on a pre-23 daemon an
unfiltered one would delete named volumes too.
- Freed space is the sum of the deleted volumes' sizes (volumes share no
data), from the size cache, which is invalidated afterwards.
- POST /api/volumes/prune takes all= and background=; without background
it still waits and returns {VolumesDeleted, SpaceReclaimed}.
GET /api/volumes/prune-status is admin-only.
- volume_sizes() ("Compute sizes") now uses the long-timeout client; it
walks every volume and could hit the same 60s limit.
The progress machinery moved into services/prune_job.py (PruneJob,
PrunePlan) and, on the frontend, the usePruneJob hook and PruneProgress
component, shared by both pages. The status now carries a phase
(preparing / removing / finishing) so the parts without a count say what
they are doing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
faff2be944 |
Show live progress while pruning images (0.61.0)
docker image prune is a single blocking call that reports nothing until it
is done. With -a on a host with many images that meant minutes of a
spinner, and past the proxy timeout an error while the daemon carried on.
The prune now removes its candidates one by one and publishes how far it
is. The candidates are the ones Docker would pick: dangling only by
default, with -a every image no container (running or stopped) uses. The
UI starts the prune in the background (POST /api/images/prune?background=
true, 202) and polls GET /api/images/prune-status. A progress bar above
the image table shows x / y, the image being removed and any failures,
and a toast reports the outcome. It survives a reload and shows up in a
second tab.
- Multi-tag images lose their tags one by one instead of force=True, so a
container created since the listing still makes the daemon refuse.
- One refused image does not stop the rest; up to ten errors are kept.
- Docker's own prune runs last to sweep up leftovers.
- Freed space is measured (image LayersSize before and after) rather than
summed, which would count base layers shared with the images that stay.
- Without background=true the endpoint still waits and returns the old
{ImagesDeleted, SpaceReclaimed} shape, so API-token scripts are
unaffected. A second prune while one runs gets 409.
- The audit entry now counts deleted images, not Docker's untag entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f766ded528 |
Stop long prunes failing with a bare 500 (0.60.1)
A prune that deletes a lot of data (image prune -a in particular) easily runs past docker-py's 60s read timeout. requests' ReadTimeout is not a DockerException, so it slipped past safe_call and the UI only showed "Request failed with status code 500". Prunes now use a client with a 540s timeout (below nginx's 600s proxy_read_timeout), and safe_call turns transport timeouts and connection errors into DockerError, so they surface as a 502 with a message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8edea8f971 |
Add OIDC single sign-on, configured from Settings (0.60.0)
Authorization Code with PKCE against any provider that publishes a discovery document, configured entirely from the UI — no environment variables, no restart to fix a typo in a client id, and a Test button that fetches the provider's metadata and says what it found. The best decision here was not writing any new session machinery. The callback sets the same httpOnly refresh cookie a password login sets and redirects to "/", and the SPA's existing boot-time restore() trades it for an access token. So an SSO session *is* a normal session — same revocation, same token_version checks, same everything — and no token is ever put in a URL fragment or query string where a proxy log or the browser history would keep it. The alternative everyone reaches for first, redirecting with #access_token=..., would have been a second code path and a worse one. What is actually verified, because "the provider said so" is worth nothing otherwise: the ID token's signature against the provider's published JWKS (re-fetched once if the kid is unknown, so key rotation heals itself), issuer, audience, expiry, and a nonce minted for that specific login. The state row is deleted when it is consumed, which is what makes a replayed callback fail, and it lives in the database rather than a dict so it survives the worker restart that can happen between the redirect out and the redirect back. Accounts match on sub, not username. It is the only identifier a provider promises is stable, so somebody renamed upstream stays the same account instead of silently acquiring a second one. An existing local account with that username is linked rather than duplicated, and keeps its role — linking must not quietly demote an admin. Claim-based admin mapping works in both directions: removed from the group upstream means read-only on the next sign-in. Two things this turned up that were already broken. verify_password raised passlib's UnknownHashError on a hash it could not parse, so a password attempt against an SSO account — which stores a deliberately unusable marker — would have been a 500 rather than a 401; it now returns false for any unparseable hash, which is the right answer for a corrupt row too. And the bundled nginx never forwarded X-Forwarded-Proto, so uvicorn saw plain HTTP behind TLS: the derived redirect URI came out as http:// and the refresh cookie lost its Secure flag. Both fixed. The password form stays on the login screen no matter what. A provider outage locking you out of the machine that runs your provider is a failure mode worth designing against. The authorization matrix made me write down why three routes are public, which is the right question to be asked: they are the path by which an unauthenticated person becomes an authenticated one. status deliberately returns only a boolean and a label — no issuer, no client id — so it tells a stranger nothing the button would not. 31 tests, with a throwaway RSA key standing in for a provider so verification is exercised for real rather than mocked: wrong key under the right kid, wrong audience, wrong issuer, expired, replayed nonce, reused state. Plus an end-to-end run of the whole flow — redirect, callback, cookie, session, group-mapped admin, replay refused, password login against the SSO account cleanly refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a1cd14a1cd |
Scan images for known vulnerabilities (0.59.0)
The Images page knew what was running and whether it was current. It could not say whether any of it was exploitable, which is the question people actually have about a homelab full of images they pulled once and forgot. Trivy runs as a throwaway container rather than being installed into StackPilot's image, reusing the helper-container pattern backups already use for volume contents. Three reasons: a 100 MB security tool and a vulnerability database that changes weekly have no business in a release artifact, pinning SCANNER_IMAGE is then a real version control, and the scanner updates itself by pulling a newer tag. It gets the socket read-only so it inspects images the daemon already has instead of pulling them again, and a named volume for its database so the ~50 MB download happens once rather than per scan. The number the UI leads with is "fixable", not the total. A base image with 300 unfixable low-severity CVEs is not a task and a page that shows 300 in red teaches people to ignore it; three findings with a fixed version available are something to do this afternoon. Counts are stored per severity, findings are sorted worst-first and capped at 200 — every finding is counted, only the list is trimmed, so the cap can never hide the severity distribution. The failure mode this had to avoid is a security feature that reads as clean when it is broken. A scanner that cannot run stores the error and *keeps the previous counts* rather than resetting to zero, so a transient daemon problem does not silently turn a bad image green. There is a test for exactly that, and another for unparseable output. Staleness is handled the same way: the local image id is recorded with the scan, and pulling the image marks the result stale instead of presenting yesterday's numbers for today's bytes. Sweeps are deliberately serial and singly-locked. Scanning is CPU- and IO-heavy, and running eight at once on a homelab box would starve the very containers the scan is meant to protect. docker-py is synchronous, so the scan itself goes to a thread — otherwise a ten-minute scan blocks every other request on the loop. Reading results is allowed for the read-only role, which the authorization matrix made me justify in writing: CVE ids and package versions for images whose tags and compose files that role can already see, and polling them is the monitoring use case a read-only API token exists for. Running a scan stays admin-only because it spends real CPU. 20 tests against a report shaped like Trivy's real output, covering the counting, the fixable number, worst-first ordering, the cap, both failure paths, staleness, and that two sweeps cannot overlap. Verified end to end through the API as well, including that a failed rescan keeps its previous counts and shows the error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9247ff9621 |
Deploy stacks from a Git repository (0.58.0)
StackPilot's stacks were already plain folders on disk, which makes GitOps less
of an architectural change than it would be elsewhere: a sync is "make these
files match that repo, then compose up". Almost all of the design effort went
into the word "these", because getting it wrong destroys data.
A stack folder is not just the compose file. Compose creates bind-mount
directories in it — ./config, ./data — and those hold the live state of whatever
is running. So the obvious implementation, clone into the stack folder and
git reset --hard, is a data-loss bug waiting for its first `git clean`. Instead
the clone lives in a cache under ${DATA_DIR}/git/<stack> where reset and clean
are safe, and the configured subtree is copied across. No .git ends up in the
stack folder, so backups and the file browser are unaffected too.
Deletion is the other half. Making a folder "match" a repo naively means
removing what the repo does not have, which is exactly the application data
above. So each sync records the paths it wrote, and the next sync may delete
only those — a file the repository never provided cannot be touched by any code
path here. Tested directly: a database file and a hand-written .env survive a
sync that replaces the compose file and removes a file the repo dropped.
What the repo does provide is overwritten, hand edits included. That is the
point of GitOps rather than a wart, but it is a surprise if you attach a repo to
a stack you have been editing, so the connect form says it before the first sync
and the first sync is never automatic.
The webhook is the only route in StackPilot with no bearer token, because a Git
forge has none to present. It authenticates with an HMAC over the body —
X-Hub-Signature-256 for GitHub/Gitea/Forgejo, X-Gitlab-Token for GitLab, both
compared in constant time — and answers 404, not 403, to anything unsigned. A
403 would confirm that a given stack exists and is connected to a repository,
which an unauthenticated caller has not earned. The authorization matrix test
caught this route being public and made me write that reasoning down in it,
which is exactly what that test is for.
Credentials never reach a command line: ps is readable by every process on the
host, and this runs in a container next to everything else. The HTTPS token goes
to git through GIT_ASKPASS and the environment, the SSH key through a 0600 file
kept outside the working tree, and everything git prints is scrubbed of both —
plus any credential-carrying URL — before it is stored in last_error or shown.
Auto-deploy takes the same per-stack lock as every other lifecycle action, so a
webhook firing mid-deploy reports "files synced, stack busy" instead of racing a
second compose run at the same project.
The image needed git and openssh-client, which is the only reason this release
touches the Dockerfile.
26 tests against real repositories created with the real git binary, none of
them touching the network — mocking git would mostly test the mock. Verified end
to end as well: connect, sync, a push that changes one file and deletes another,
a wrongly signed webhook, a correctly signed one, and the live data still there
afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e650aa6833 |
Add API tokens for scripts and CI (0.57.0)
A session token is the wrong credential for automation. It expires in an hour, it is minted by typing a password, and revoking it signs every one of that person's devices out. So automation gets its own credential, revocable on its own, and showing up in the audit log as itself. Three decisions worth recording, because each one is a place this could have been built wrong. **Only a hash is stored.** This is the opposite call from registry passwords one release ago, and for a concrete reason: a registry password has to be handed back to the registry, so it must be recoverable and is encrypted. A token is only ever compared against, so it does not need to be — and not keeping it is the difference between leaking the database and leaking everything the database protects. It is shown once and cannot be recovered; a readable prefix is kept so rows are still identifiable in the UI and the audit log. The hash is SHA-256, deliberately not bcrypt: bcrypt is slow to make guessing low-entropy human passwords expensive, and a token is 256 bits of secrets output, so the cost would buy nothing and would land on every single API request. **The scope is not folded into the User object.** get_current_user returns a session-attached row; downgrading its role in place to represent a read-only token would be written back to the database the next time anything committed that user — logout-everywhere does exactly that. So the token row is stashed on request.state and require_admin consults it, leaving the User untouched. The same lookup caps a token at its owner's authority rather than trusting the scope alone, so a demoted admin's token drops to read-only with them and a disabled account's tokens stop working. **A token cannot make itself permanent.** Creating tokens and creating users now require a signed-in session, via a require_session dependency that rejects token-authenticated requests. Without it, a leaked CI credential could mint a second one and survive its own revocation — the failure mode where revoking the leak does nothing. This is the one behaviour change for existing installs: scripted user creation now needs a login. The WebSocket routes still take JWTs only. They carry logs, the terminal and the deploy console, which a CI job has no use for, and leaving them alone keeps the token surface to the REST API. 19 tests, covering what is stored, that a read token really is read-only while its owner is an admin, that demoting and disabling the owner both take effect, expiry, tampering, the throttle on last-used writes, and that a token can neither mint another nor create a user. Verified end to end against a running app: two tokens, both scopes, revocation, and no plaintext anywhere in the database or the list response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
95e03f031f |
Add private registry credentials, and stop update checks lying (0.56.0)
This was on the gap list as a missing feature, but it was a bug first. The
update checker asks the registry for a tag's digest over HTTP itself, and could
only do it anonymously. A private repository answers 401, remote_digest returned
None, and None already meant "could not reach registry" — so a private image was
indistinguishable from a network blip. The Images page showed nothing and a
stack pinned to a six-month-old image looked up to date indefinitely.
So AuthRequired is now its own exception, separate from unreachable, and the
error names the registry and which of the two problems it is: "ghcr.io needs
credentials" when there are none, "ghcr.io rejected the stored credentials" when
there are and they are wrong. Those are different fixes, and the message should
say which one you need. The plain unreachable message survives unchanged, with a
test pinning it, because not every failure is an auth failure.
Two consumers need the credentials and they need them in completely different
shapes, which is why this is its own service rather than a field on something
else. StackPilot's own checker wants (user, password) inside async code that has
no database session, so the rows are mirrored into an in-memory cache that
reload() refills on startup and after every write. The Docker CLI wants a
config.json, so reload() writes one into ${DATA_DIR}/docker and compose runs with
DOCKER_CONFIG pointed at it. Generating it from the database every time is what
makes deletion real: removing a registry in the UI revokes the CLI's login
instead of leaving a stale one in ~/.docker.
Host normalization is the join that makes any of it work, and it is easy to
underestimate. parse_ref only ever produces registry-1.docker.io, nobody types
that, and the CLI wants the whole thing under https://index.docker.io/v1/ — three
spellings of one registry across three layers. canonical_host settles on what
parse_ref produces, the config writer translates on the way out, and a bare
nginx:alpine finds credentials entered as "docker.io". Verified end to end:
typed as the v1 URL, stored as registry-1.docker.io, written as the v1 URL.
The password is encrypted at rest with the same key as backup destinations and
never leaves the server, not even masked — the API returns has_password, which
is all the form needs to offer "leave blank to keep". A row that cannot be
decrypted after a SECRET_KEY change is skipped with a warning rather than taking
every other registry down with it. Everything here is admin-only including the
reads, because even masked the rows say which registries this install talks to
and under what account.
The Test button asks the registry rather than validating a string, following the
Bearer challenge with credentials attached the way a real client does. Only an
outright 401 counts as wrong credentials; anything else means reachable and
talking, which is as much as a credentials check can honestly claim. Checked
against the live Docker Hub token endpoint with deliberately wrong credentials.
33 tests: the normalization table, the cache, the generated config.json down to
its 0600 mode and the Docker Hub key, encryption at rest, that no password field
appears in any response, and the 401-is-reported behaviour that started this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b629d1b2c2 |
Use the apps' real logos as stack icons, fetched server-side (0.52.0)
0.51.0 gave every stack an icon, but a generic one: jellyfin got a clapperboard,
not the Jellyfin logo. Glyphs make a list readable; they do not make a stack
recognisable, which was the point. This resolves stacks against the selfh.st
icon catalog (~2900 self-hosted apps, the set Homarr and Homepage draw on), so
the row shows the thing people already recognise. All 83 bundled templates
resolve to their own logo.
The whole design question was *who* talks to the CDN. If the <img> points at
jsdelivr, then every client needs internet, every page load leaks the names of
somebody's stacks to a third party, and an air-gapped box gets nothing. So the
backend does it: the catalog on startup and weekly after, each logo once on
first use, both into ${DATA_DIR}/stack-icons/. Browsers keep reading icons from
the authenticated endpoint that already existed for uploads, and after the first
fetch the feature is fully offline. Logos are cached per *app*, not per stack —
verified: two stacks resolving to jellyfin produce one download.
Nothing here can fail loudly. Every entry point returns None rather than raising
when the network is absent, the catalog refresh is a task the lifespan does not
await, and an install with no outbound internet simply keeps 0.51.0's glyphs.
That fallback is also what covers a name the catalog does not know
("Mediaserver Wohnzimmer" is still a clapperboard), and the seconds after a
fresh install before the catalog lands. The glyph is derived even for stacks
that *do* have a logo, so an image that cannot be fetched degrades to something
meaningful instead of a box.
Matching gained a second source that turned out to matter more than expected:
the compose images. A stack called "medienserver" says nothing, but it pulls
lscr.io/linuxserver/jellyfin — strip the registry, the vendor and the tag and
the app is right there. Name first, then the longest run of words inside it,
then the images. It is deliberately cautious: a single word shorter than four
characters never claims a logo, because "web", "app" and "db" are all catalog
entries and a *wrong* logo is worse than a neutral glyph. A short alias table
covers what the catalog spells differently from Docker Hub (postgres →
postgresql, pihole → pi-hole, wg-easy → wireguard).
A slug arrives from the database and from query strings and then becomes a
filename, so it is pattern-checked before it is ever joined to a path, catalog
entries that are not slug-shaped are dropped on load, and a downloaded logo is
verified to start with the PNG magic bytes before being cached.
The picker searches the catalog too — pre-seeded with the stack's own name, so
opening it on "jellyfin" offers the Jellyfin logo first — which is how a wrong
match gets corrected, and how a stack can be given any app's logo on purpose.
Verified end to end against the live catalog and real downloads: list rows carry
the resolved logo, the icon endpoint serves real PNG bytes, an unmatched stack
404s (and falls through to its glyph), a hand-picked logo round-trips, reset
clears it, and a traversal slug 404s. 30 new backend tests and 12 new frontend
ones run without any network at all.
0.52.0 rather than amending 0.51.0: those images are already in the registry,
and rebuilding a published version tag with different content is exactly what
breaks the self-update checker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7682460b4f |
Give every stack an icon, and put the status on it (0.51.0)
Stacks were a name and a coloured dot. The dot carried the status but nothing
carried identity, so a list of twenty stacks read as twenty identical rows.
This gives each one an icon in front of its name and moves the status onto that
icon as a halo in the status colour, which is the thing the eye lands on anyway.
The constraint that shaped the design: people already have stacks. Asking them
to pick an icon for each one before the feature does anything would mean it
never gets used, so the icon is *derived* from the stack's name and the column
stays empty until somebody overrides it. ~700 keywords in 79 groups cover the
self-hosted long tail (jellyfin -> clapperboard, vaultwarden -> key,
home-assistant -> house) plus generic English and German terms; the longest
match wins, so photoprism beats a bare photo, and short keywords like "tv" only
match as whole words. No backfill, no migration, and a rename moves the icon
with it.
That is also why the catalog and the matcher live in the frontend. It is the
only place that can render an icon, so a copy in the backend would be a list to
keep in sync and nothing else. The server validates the shape of the stored
value and stores uploads; it never needs to know what "lucide:database" looks
like. An icon name that later leaves the catalog falls back to the derived one
rather than blanking the row.
Overriding happens in two places, because there are two moments: the editor
(holding a chosen file until the stack exists, since uploading needs an id) and
a click on the icon on the detail page, which is how a stack that has existed
for a year gets one without a trip through the editor.
Uploads are classified by their bytes, not by the filename or Content-Type the
browser claims, and land in ${DATA_DIR}/stack-icons/ under the stack id. SVG is
allowed — <img> does not execute it — but the endpoint serves every icon as an
attachment so one can never be opened as a document in the API's own origin. A
client-supplied "custom:" value is refused: the server mints those, so a stack
cannot be pointed at a file it does not own. Files follow the stack: replaced on
re-upload (including across formats, or the old one orphans), copied on clone,
removed on delete.
The one piece of plumbing worth knowing about: the icon endpoint needs the
bearer token like everything else, and an <img src> would not carry it. So
StackIcon fetches the bytes through the API client and renders the blob, keyed
on the stored value — which carries an upload timestamp precisely so a re-upload
changes the key and retires the cached image.
Covered by 22 backend tests (the value rules, byte-sniffing, the file lifecycle,
the API round-trip, and that the read-only role cannot change an icon) and 29
frontend ones for the matcher. The schema change was verified against a
hand-built pre-0.51 database: the column is added on start and existing rows
come back NULL, i.e. automatic. Not click-tested in a browser — no Docker in
this environment — so the row height the taller icon produces is unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
51d1998307 |
Remove the remote-host (agent) integration (0.48.0)
StackPilot now manages exactly one Docker host: the one it runs on. The
stackpilot-agent sidecar and everything that proxied to it are gone — 4721
lines deleted against 657 added.
Deleted outright: agent/ (image, compose, env), agent_app.py, models/agent.py,
routers/agents.py (1200 lines), services/agent_service.py, the agent API client,
RemoteStackDetail, the host components and AgentStacksSection. That removes 57
API routes and the three /ws/agent-* proxies.
Threaded out everywhere else, which was the bulk of the work. Every API module
carried an optional agentId that switched the base path; every page that listed
Docker objects rendered one section per host behind a HostHeader; Files had a
host switcher; the New Stack editor and the template dialog had host selectors;
schedules, auto-update policies and stack summaries carried agent_id. All of it
is gone, and the typechecker drove the sweep — 85 files touched, tsc and the
build clean.
Two things the removal exposed as dead weight rather than merely unused:
compose_service kept an in-process busy set purely because the agent needed a
lock and has no database. With the agent gone that was a second source of truth
next to the real DB lock, so it is deleted; compute_status now reports only what
the containers say and the two callers that want "updating" overlay the lock.
StacksTable's linkBase prop only ever existed to point at /hosts/{id}/stacks.
The dashboard's "Hosts 1/1 online" KPI can no longer say anything else, so the
tile and the KPIs behind it are gone and the row is five wide.
Upgrading matters here. An existing install still has an agent table holding
each remote host's URL and bearer token — full Docker control of that host,
sitting in the database with nothing left to use it. _drop_removed_schema drops
it on first start, and drops the agent_id columns where the SQLite build
supports DROP COLUMN. Each statement runs in its own transaction on purpose: a
failed DDL poisons the transaction it is in, so sharing one would let an
unsupported column drop take the table drop down with it. test_agent_removal
covers both branches plus the fresh-install and idempotent cases, and an
end-to-end run against a seeded pre-0.48 database confirms the table is gone and
every /api/agents route answers 404.
Docstrings that justified a design by "shared with the agent, which has no
database" were rewritten rather than left lying: update_service's persistence
callback and image_status_store are still the right split (registry logic stays
testable without a database), but for that reason now, not the old one. The
README's multi-host sections are removed and an upgrade note explains what to do
with running agent containers; ROADMAP keeps its history behind a note saying
the feature it describes no longer exists.
CI no longer builds or pushes stackpilot-agent.
735 tests pass, ruff and tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
|
||
|
|
09bed274eb |
Lock stacks during compose runs, cache stats, persist runtime state (0.47.0)
F7 — Nothing stopped two compose operations landing on the same stack. There was a busy flag, but is_busy() was only ever read to colour the status column; no lifecycle handler consulted it before acting. Two tabs, or auto-update picking up a stack somebody had just clicked, both ran pull + up -d against the same project and raced over recreating containers. Lifecycle calls, the two deploy WebSockets and the auto-update pass now take a real lock; a second caller gets 409 (or an error frame and close 4409) and auto-update skips and retries next cycle. The lock is a row rather than a set in one worker's memory, so it holds across workers and across a restart, and it carries an expiry — a worker killed mid-deploy would otherwise strand the stack with no fix short of editing the database. F10 — /api/stacks/stats sampled every running container on every call, one blocking daemon request each, and both the dashboard and the stacks list poll it every five seconds. Two tabs on a 40-container host meant a sustained ~16 samples a second. Cached for 4s behind a lock so concurrent callers share one sweep, the same shape dashboard_service already used for its fleet aggregate. F11 — Three module dicts assumed exactly one uvicorn worker without saying so and were lost on restart. The busy set is the lock above. The image update cache is now mirrored to SQLite, so a restart shows the badges immediately instead of blanking them for up to an hour, and the already-notified marks come back with them rather than re-announcing the same updates. The login rate limiter is a table, so it cannot be cleared by getting the process to restart and no longer multiplies by the worker count. The constraint that shaped this: compose_service and update_service are shared with the agent, which has no database. Neither may import one. So the lock is a separate service the central app enforces at its own entry points, and update persistence is an opt-in callback the central app registers in its lifespan — the agent registers nothing and behaves exactly as before. A test asserts update_service never imports the database, since that is the kind of thing a later change breaks silently. Both new nets were checked by reverting the fix: dropping the lock from _lifecycle fails six tests, removing the stats cache fails the one that names the behaviour. Also wires up cache pruning in the same sweep — without it both the dict and the table grew one entry per image tag ever run, for the life of the install. 31 new tests (729 total). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG |
||
|
|
60a7ccff93 |
Add a test suite, a linter and a CI gate in front of the build (0.45.0)
The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.
670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.
test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.
test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.
Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.
test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.
The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.
ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).
CI now runs check (ruff, pytest, tsc) and only builds if it passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
|
||
|
|
54c835b032 |
Close the read-side privilege escalation and fix proxy-aware IPs (0.44.0)
CI / build-and-push (push) Successful in 3m53s
F1 — Any authenticated user could read any file the backend could see.
/api/files/read and /download hung on get_current_user, and the sandbox that
should have caught that was open by default: ALLOWED_BROWSE_ROOTS contained
"/", for which _is_allowed() waves through every path. So the `user` role could
download stackpilot.db (password hashes, agent tokens, backup credentials),
every stack's .env and every .secrets/* file — with no audit trail, because
only mutations were logged.
Implementing that turned up three more doors into the same room, all fixed
here since closing only the first would have made the fix cosmetic:
GET /api/stacks/{id} handed the .env to any user, /export tarred the whole
stack dir including .secrets/*, and both the agent file proxies and
/api/agents/{id}/stacks/{id} repeated the leak for every remote host. All 24
filesystem-touching routes are now admin-only; reads and downloads are audited
(listing is not — the Files page polls it). DATA_DIR is refused outright, since
the API deliberately masks agent tokens and destination secrets and the browser
would otherwise be the way around that. "/" is out of the default browse roots.
F2 — Backup destination credentials were plaintext JSON in the DB, which is
what made F1 worth exploiting. They are now Fernet-encrypted at rest behind
parse_config/dump_config, with existing rows migrated at startup.
This needed a prerequisite from F6: the key is derived from SECRET_KEY, which
was regenerated on every boot when unset. Encrypting against a key that changes
per restart would be worse than plaintext, so an auto-generated SECRET_KEY is
now persisted to ${DATA_DIR}/secret_key at mode 0600. Sessions surviving a
restart is a welcome side effect.
F3 — /api/audit is admin-only. Also hidden from the dashboard and the nav for
non-admins, so nobody polls into a 403.
F4 — uvicorn now runs with --proxy-headers, so nginx's X-Forwarded-For is
honoured. Without it request.client.host was the frontend container's IP for
every request, which made the login rate limit global instead of per-IP (10
failures locked out everyone) and filled the audit log's IP column with one
useless value.
Verified: encrypt/decrypt round-trip incl. plaintext passthrough, idempotent
re-encryption and wrong-key handling; sandbox denial for DATA_DIR, traversal
into it, and paths outside the roots, with the allowed roots still reachable.
Both against stubbed settings — there is no Docker here, so nothing was run
end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
|
||
|
|
1e8d4248fd |
Stream update progress into a bar on the stack's row (0.42.0)
CI / build-and-push (push) Successful in 1m55s
Update ran as a blocking POST with nothing to show but a spinner, so the
status added in
|
||
|
|
adfd77a983 |
Fix NFS uploads against root_squash exports (0.40.2)
Uploading a backup extracted the tar straight into the NFS mount via
put_archive, and the daemon chowns every entry while extracting — an export
with root_squash refuses that ("failed to Lchown ... for UID 0, GID 0:
operation not permitted"), so the upload died with a docker 500 even though
plain writes to the share work (which is why the destination test passed).
The helper container now unpacks into its own filesystem and copies the file
into the mount with cat, which never chowns. Restores hit the same wall when a
volume or bind folder lives on a squashed mount, so import_path/import_volume
fall back to a copy-through-staging when (and only when) the failure is a chown
denial — local restores keep preserving ownership. NFS file names are validated
against the same safe charset as the subdir parts, since both are interpolated
into the helper's shell commands.
Verified against a real root_squash NFS export: test/upload/list/download/delete
round trip, byte-identical download, restore into an NFS-backed volume via the
fallback, and ownership still preserved (1000:1000, 0600) on a local volume.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4c158e9407 |
Fix NFS backup destinations broken by the 0.40.0 refactor (0.40.1)
_ensure_helper_image moved to stack_assets_service, but backup_destination_service imports it lazily inside _nfs_run/_nfs_helper, so nothing failed at import time — every NFS destination operation raised ImportError at runtime instead. The helper is now a public ensure_helper_image() and the NFS helpers import it from its new home. Verified: every services/ and routers/ module imports, and both NFS helper paths run through to a Docker call instead of ImportError. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5347a36eaf |
Back up bind-mount data, not just the compose file (0.40.0)
A stack's real state lives in its bind-mounted config directories, and those
were never captured: the backup only tarred the stack folder as this container
sees it. When STACKS_HOST_DIR differs from the container's STACKS_DIR, compose
resolves ./config against the container path and the daemon creates it at that
path on the *host* — invisible here, so the archive held little more than
compose.yaml and .env.
New services/stack_assets_service.py inventories a stack's data (bind sources
merged from container mounts + the compose file, named volumes) and does all
data I/O through a throwaway helper container, i.e. by host path, so unseen
directories are captured anyway. It also detects the host/container stacks-path
mismatch and reports it.
- manifest v2: full inventory, per-asset capture result, skip reasons (v1 still
restores)
- NFS/CIFS-backed volumes are skipped by default and never wiped on restore
- deselected data inside the stack folder no longer sneaks in via compose/
- volume/bind archives stream through temp files instead of RAM
- restore preserves mode, ownership, mtime and symlinks, and writes bind folders
back to their host paths (rewritten when the stack is renamed)
- backup dialog shows the inventory with sizes and per-item checkboxes; restore
gained a "restore bind folders" toggle
- new GET /api/stacks/{id}/backup/inventory (+ agent + proxy), backup endpoints
take include_binds/binds/volumes, restore takes restore_binds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ecf780c5e6 |
Deploy console: real progress bar for image pulls (0.39.0)
Compose is now run with `--progress json` (probed once, falls back to the plain text stream on older compose/agents). The console folds the event stream into a weighted progress bar — download bytes per layer, then container create/start — with a per-image bar and a byte/layer counter, and renders the raw output one line per layer (updated in place) instead of a wall of scrolling text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9119f94536 |
Clear the stack update pill immediately after a manual/auto update (0.38.4)
The amber image-update indicator is fed from update_service._CACHE, which only the background loop refreshed — after a per-stack Update/Pull the stale digests kept the pill on until the next pass. Now the local digests are reconciled with the cached remote digests right after a successful pull/update (local backend, agent lifecycle, auto-update pass), and the frontend invalidates the stack-updates queries after actions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c830d28b65 |
Dashboard: rebuild into an operator cockpit (0.38.0)
Replace the analytics-style dashboard (stack-health funnel, uptime %, operations/day grid, AI pill) with an attention-driven fleet cockpit: - New /api/dashboard/fleet endpoint: server-side fan-out across the local host and every agent into one payload — a prioritized "needs attention" list, headline KPIs, an honest stack-status breakdown and a per-host resource rollup. Each agent uses its own DB session so the fan-out is concurrency-safe; failures degrade to "offline" instead of stalling. - New frontend: AttentionStrip, FleetKpiRow, StackStatusBar and HostResourceTable; Dashboard.tsx rewritten around them. - Remove the funnel/summary endpoints, the uptime sampler loop and the ops-activity machinery; delete the now-unused chart components. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5ac9f15de4 |
0.37.2: stream folder zip-downloads to fix 504 on large folders
A big folder hit a 504 Gateway Timeout: the zip was built into a temp
file *before* any response was sent, so for large folders the backend
stayed silent past nginx's proxy_read_timeout.
Now the zip is streamed as it's built, end to end:
- file_service.open_archive() returns (filename, byte iterator); _iter_zip
walks the dir and yields zip bytes incrementally via a small drain
buffer, writing each file in 1 MiB chunks (bounded memory, valid CRCs).
Same hardening as before — only real regular files; FIFOs/sockets/
devices/symlinks skipped without open(); per-file read errors skipped.
- /api/files/download and /agent/files/download return a StreamingResponse
(no temp file). The agent proxy streams the agent response straight
through (agent_service.stream_download), pulling the first chunk eagerly
so an offline/bad-token agent still yields a clean status before 200.
- Files page: streamed downloads have no Content-Length, so the progress
bar shows the running downloaded byte count ("Downloading … 12.3 MB")
instead of a percentage, after the initial "Preparing …".
Verified end to end via TestClient (200, application/zip, valid zip,
2 MiB file intact, FIFO skipped, no hang).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
0dc430bb2a |
0.37.1: fix folder-download hang/501 on special files + add download progress
Two issues with the 0.37.0 folder zip-download: 1. Hang / server error (reported as 501) on "some folders". archive_dir tried to zip every entry, including non-regular files. Opening a FIFO blocks forever (no writer); a unix socket / unreadable file raised an OSError that aborted the whole archive. Now only real regular files are zipped — FIFOs, sockets, devices and symlinks are skipped without ever open()-ing them, and a per-file read error skips just that file instead of failing the download. 2. No feedback while a large folder is being prepared. The zip is built server-side before any bytes flow, so the click felt dead. The Files page now shows an indeterminate "Preparing <name>…" bar from click, switching to a real percentage during the transfer (Content-Length is known for the finished zip). filesApi.download forwards onDownloadProgress. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f1782eca0e |
0.37.0: download whole folders (recursive) as a .zip from the file browser
The file browser/editor could only download individual files. Add a recursive directory download that streams the folder as a zip archive, on the local host and on every remote agent. - file_service.archive_dir(): zip a directory recursively into a temp file, preserving the folder name as the archive root and empty subdirectories; symlinks are skipped (no sandbox escape / loops). - /api/files/download and /agent/files/download branch on directories and return application/zip, cleaning up the temp file afterwards. - Files page: show the download button for folders too (as <name>.zip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
cd15cdc75e |
0.36.0: per-stack image-update indicator on the Stacks overview
Shows an amber "Update" pill next to a stack's status (and highlights the
inline Update button) when any of the stack's images has a newer digest in
the registry. Reuses the existing background image-update check — a new
update_service.stacks_update_summary() reads the cached digests in a single
container sweep (no extra registry calls), exposed as GET /api/stacks/updates
and proxied per agent at GET /api/agents/{id}/stacks/updates. The Stacks page
and each remote-host section poll it every 60s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
786c346c40 |
0.33.0: NFS share as backup destination
New destination type 'nfs' alongside SFTP and S3. The StackPilot container needs no mount privileges: the Docker daemon mounts the export as a named volume (stackpilot-nfs-dest-<id>, driver local/type nfs, recreated whenever server/path/options change) and all file I/O runs through throwaway helper containers (BACKUP_HELPER_IMAGE) — upload via put_archive, list via stat, download via get_archive, delete/test via short-lived runs. Config: server, export path, mount options (default rw), optional subdirectory (sanitized; shell-safe charset). Mount failures surface as clean destination errors. Settings UI gains the NFS form + summary; works everywhere destinations are used (push, restore-from, scheduled backups incl. retention). Verified live against a real kernel NFS server: test, push (file on the export), list, restore-from incl. volume data, remote delete, config change recreates the mount volume, unreachable server fails cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
79d82361d8 |
0.32.1: backup/restore fixes (audit findings, all paths live-verified)
- backup_filename() crashed with NameError (bare now()) since 0.8.0 — broke every scheduled backup at the upload step, agent backup download and the central remote-backup/push endpoints. The local manual path worked only because the router had its own copy (now an alias). - restore: the manifest stack_id from an uploaded backup is now slugified too — a crafted '../../...' id could previously escape STACKS_DIR. - create_backup no longer starts a previously-stopped stack (stop/restart only when the stack was actually running). - overwrite-restore wipes the existing volume contents before extracting, so files created since the backup no longer survive underneath it. Verified end-to-end: full/config backup contents (compose, .env, .secrets, bind dirs, extras, volume tars), delete→restore round-trip incl. volume data, rename restore with volume re-prefixing, 409 conflict + overwrite, traversal guard, scheduled run + retention prune + restore-from against real MinIO, and the complete remote-agent cycle (download/push/restore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a0dda120f5 |
0.32.0: StackPilot self-update (check on page load + one-click update)
- backend/version.py is now the single version source (main.py, agent). - GET /api/system/update: reads the version tags of the backend's own image repo (anonymous v2 token flow, https→http fallback for insecure registries), compares the highest semver tag against APP_VERSION; reports update_supported from the container's compose labels. 10 min cache. - POST /api/system/update (admin, audited): spawns a detached helper container from the current backend image that runs docker compose pull && up -d on StackPilot's own compose project (project name, working dir and config files resolved from its own container labels) — the helper outlives the backend being recreated. Non-compose installs get a 400. - /api/health now returns the version so the UI can detect the switchover. - TopNav version badge: queries the update status on page load; when a newer release exists an amber pill shows the version — one click (admin) confirms, triggers the update and overlays a wait screen that polls /api/health and reloads once the new version answers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
11effdc2ca |
0.31.1: make dashboard metrics honest
- Container card now compares compose-only counts across hosts: agents report compose_running in /agent/system (pre-0.31.1 agents fall back to the all-containers number); card retitled, ResourceBar stat labelled 'Containers (all)'. - Uptime is sampled every 5 min (background loop + opportunistic on read) and charted as daily averages instead of a once-a-day snapshot; no sample is written when no compose containers exist (was: fake 100%). Legacy daily entries in uptime.jsonl still count; file pruned at startup. - Funnel stage 'monitored' is now per-stack and real: stacks with an enabled local auto-update policy (was: global webhook-exists toggle). Frontend label renamed to 'Auto-managed'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1609b8bcc3 |
Phase 25: templates as stack folders (0.31.0)
Templates are now stack-shaped folders (compose.yaml + .env.example +
template.json) instead of DB rows + manifest.json + {{VAR}} rendering.
Pull copies the folder into a new stack; custom templates persist under
DATA_DIR/templates. Adds POST /api/templates/from-stack and a one-time
startup migration for pre-0.31 DB templates (drops the template table).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
34cb215266 |
Phase 24: Design System v2 — analytics-style UI (0.30.0)
- New /api/dashboard/funnel (5-stage stack health, 30s TTL cache) and /api/dashboard/summary (containers, daily uptime jsonl, ops activity) - Token system (tokens.css + Tailwind sp-* aliases); legacy bg/card/accent remapped onto the tokens; Schibsted Grotesk bundled via fontsource - TopNav pill navigation + AppShell replace the sidebar layout (off-canvas drawer below 1024px); central display-weight page titles - Dashboard redesign: FunnelChart (gradient/hatch SVG waterfall), container count card with per-host bars + Insights chip, UptimeChart, OpsGrid, AiPromptBar; 30/7-day range selector; host sections retained below - Stacks page honours ?q= / ?filter= deep links + new status-filter select Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6464e0677c |
Phase 23: per-stack secrets & configs (compose file-based), local + agent (0.29.0)
Manage Docker secrets and configs per stack from a new Secrets tab on Stack/ RemoteStackDetail. Content is stored as files inside the stack dir (.secrets/<name>, .configs/<name>; dir 0700 / file 0600) and referenced from the compose file with relative `file:` paths, so the daemon reads them without any HOST_ROOT_PREFIX dependency. Content is write-only — the API only ever returns metadata (name, kind, size). - secret_service: write/delete/list (metadata only)/exists/rel_path/attach/detach; name validation rejects traversal/hidden/separators, content capped at 1 MiB. - compose_edit_service: add/remove secret and config (top-level defs pruned when no service still references them). - routers/secrets.py (admin-only, audit secret.*) + agent endpoints + multi-host proxy (audit agent.secret.*). - Frontend SecretsPanel (create/list/delete + per-row attach/detach to a service; config rows take a mount target), agentId-aware for remote stacks. Verified: name-sandbox + perms + metadata-only listing unit-tested; compose add/remove round-trips to clean YAML; py_compile + backend/agent/frontend image builds + route smoke-test (local/agent/proxy). Live exec check (/run/secrets/<name> on a deployed stack) and swarm path are hardware-verify debt (swarm dropped: A). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
255c8441c6 |
Phase 22: auto-update (Watchtower-style), local + agent (0.28.0)
Per-stack auto-update policy on the stack Overview tab. When the background
image-update check finds a newer registry digest for one of a stack's images,
the stack is pulled + redeployed (or just flagged, "notify only"). Only running
stacks are auto-redeployed; a stopped stack is skipped, never silently started.
- models/auto_update.py: AutoUpdate(stack_id, agent_id, enabled, redeploy,
last_run/status/result) + schemas; registered in models/__init__.py.
- update_service: DB-free stack_images/stack_updates helpers (agent reuses
them); agent GET /agent/stacks/{id}/updates.
- services/auto_update_service.py: run_due/run_policy (local pull+up via
compose_service, remote via agent_service POST /agent/stacks/{id}/update,
notify-only with per-transition dedup); lazy-called from
update_service.background_loop. New stack_auto_updated notify event.
- routers: GET/PUT/run /api/stacks/{id}/auto-update and the
/api/agents/{id}/stacks/{sid}/auto-update variants (policy stored centrally).
- frontend: api/autoUpdate.ts + AutoUpdatePanel (enable, redeploy|notify-only,
Check now, last-run status) on StackDetail + RemoteStackDetail; EVENT_LABELS
gains stack_auto_updated + backup_failed.
Live-verified all four paths (updated / update-available / up-to-date /
skipped) against real compose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
be3568274f |
Phase 21: container terminal (web exec), local + agent (0.27.0)
Interactive shell into a compose-managed container over WebSocket + xterm.js,
opened from the container card on the stack Overview tab. Admin-only (non-admin
handshake rejected with 4403); only containers with the compose project label
are reachable.
- backend services/exec_service.py: create/start/resize exec + a shared
bidirectional pump_exec (recv/sendall on sock._sock, executor thread,
resize control frames, exit-code frame).
- routers/ws.py: _authorize_admin + /ws/exec/{container_id} and the
/ws/agent-exec/{agent_id}/{container_id} proxy (forwards BOTH directions).
- agent_app.py: /agent/ws/exec/{container_id}.
- frontend: @xterm/xterm + @xterm/addon-fit; ContainerTerminal modal (shell
picker, fit/resize, exit/error handling) + a Terminal button on ContainerCard.
Live-verified (TestClient): local happy/exit/guard/4403/4401, agent happy/4401,
proxy bidirectional round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
2f63247fc1 |
Phase 20: per-container inspect + start/stop/restart, local + agent (0.26.0)
Stack Overview now renders each service as an expandable ContainerCard with a
curated single-container inspect view and admin start/stop/restart buttons,
both for local stacks (GET/POST /api/containers/{id}[/{action}]) and remote
stacks (proxied via /api/agents/{id}/containers/* to the agent's new
/agent/containers/* endpoints). Only compose-managed containers are exposed.
Also bumps version 0.23.0 -> 0.26.0 (the bumps for the already-committed
Phase 18 image-prune / Phase 19 compose-validate were missed) and backfills
README sections for Phase 18/19/20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9c4d319f8f |
Phase 19: compose validate (docker compose config) + diff vs deployed in editor (0.25.0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
34c5fffa85 |
Phase 18: image prune (dangling/unused), local + agent (0.24.0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
7592085ce9 |
Live deploy console: stream compose up output to the browser (0.22.0)
Deploying a local stack from the editor now opens a console modal that streams
the `docker compose up -d` output (image pulls, container creation) live over a
new `/ws/deploy/{stack_id}` WebSocket, replacing the blind "Deploying…" spinner.
The compose subprocess keeps running server-side if the modal is closed early;
the same audit entry + start/error notification as the REST start path is recorded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
5b59f5e8f9 |
Perf: serve stacks list from one Docker call; registry host → 10.10.6.10 (0.21.6)
Stacks overview and detail were doing an N+1 inspect storm: list_stacks called containers_for_stack AND compute_status (which re-fetched) per stack, and containers.list(sparse=False) full-inspects every container plus c.image triggered an image-inspect each. For N stacks that was ~2N*(1 list + M inspects + M image-inspects) sequential socket round trips (~1s for just 2 stacks, growing linearly). - compose_service.stack_status_summaries(): one low-level api.containers(all=True) summary call grouped by compose project label → whole list served in a single Docker round-trip (~10x faster). - compute_status() takes optional pre-fetched containers; get_stack and _stack_summary no longer double-fetch. - containers_for_stack() reads the image name from the inspect it already has instead of c.image (drops the per-container image-inspect). - Same batching applied to the agent's stack list/detail. Also: Forgejo (registry + git) moved to 10.10.6.10:3020 — updated image refs in docker-compose.yml, agent/Dockerfile, agent/docker-compose.yml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |