feat(agent): report isDocker so the web shows a docker pull command

The binary self-update hard-stops inside a container (internal/upgrade
refuses when /.dockerenv exists), so the web's in-app 'force update' button
is futile for Docker agents. Report whether we run in a container via a new
RunningInDocker() helper (UNARR_DOCKER env baked into the image, /.dockerenv
fallback for podman/containerd) on every register + sync, so the web can
swap the button for a copy-paste 'docker compose pull' command.
This commit is contained in:
Deivid Soto 2026-06-03 18:09:29 +02:00
parent ccd50e7c8e
commit 2148b0e2cc
5 changed files with 42 additions and 0 deletions

View file

@ -93,6 +93,12 @@ ENV UNARR_CONFIG_DIR=/config
ENV UNARR_DOWNLOAD_DIR=/downloads
ENV XDG_DATA_HOME=/data
# Mark this as a container install so the agent reports isDocker=true to the web
# (which then shows a `docker pull` command instead of the in-app update button —
# the binary self-update refuses to run in Docker). Covers podman/containerd too,
# which don't create /.dockerenv. See internal/agent/RunningInDocker.
ENV UNARR_DOCKER=1
# NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" +
# "compute" capabilities; nvenc needs "video", and "graphics" makes the runtime
# mount the NVIDIA Vulkan ICD (nvidia_icd.json — the load-bearing piece — plus

View file

@ -148,6 +148,7 @@ func (d *Daemon) Register(ctx context.Context) error {
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
IsDocker: RunningInDocker(),
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free

26
internal/agent/docker.go Normal file
View file

@ -0,0 +1,26 @@
package agent
import "os"
// RunningInDocker reports whether the agent process is running inside a Docker
// (or compatible OCI) container. The web uses this to swap the in-app "force
// update" button — which drives the binary self-update path that hard-stops
// inside a container (see internal/upgrade) — for a copy-paste `docker pull`
// command instead.
//
// Detection order:
// 1. UNARR_DOCKER env truthy — baked into the official image's Dockerfile, so
// it also covers podman/containerd running our image (which don't create
// /.dockerenv).
// 2. /.dockerenv exists — the standard marker Docker writes into every
// container, covering images that didn't set the env.
func RunningInDocker() bool {
switch os.Getenv("UNARR_DOCKER") {
case "1", "true", "yes":
return true
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
return false
}

View file

@ -175,6 +175,7 @@ func (sc *SyncClient) buildRequest() SyncRequest {
LanIP: sc.cfg.LanIP,
TailscaleIP: sc.cfg.TailscaleIP,
CanDelete: sc.cfg.CanDelete,
IsDocker: RunningInDocker(),
}
if sc.GetTaskStates != nil {
req.Tasks = sc.GetTaskStates()

View file

@ -51,6 +51,11 @@ type RegisterRequest struct {
// CloudFlare Quick Tunnel hostname when enabled; the web prefers it over
// Tailscale/LAN for in-browser playback because it works on any network.
FunnelURL string `json:"funnelUrl,omitempty"`
// IsDocker tells the web the agent runs inside a container, so it shows a
// `docker pull` command instead of the in-app update button (the binary
// self-update refuses to run in Docker). No omitempty: false (a binary
// install) is a meaningful state the server must see to keep the button.
IsDocker bool `json:"isDocker"`
}
// RegisterResponse is returned by the server after registration.
@ -395,6 +400,9 @@ type SyncRequest struct {
VPNServer string `json:"vpnServer,omitempty"`
// CloudFlare Quick Tunnel hostname when enabled, else empty.
FunnelURL string `json:"funnelUrl,omitempty"`
// IsDocker — see RegisterRequest.IsDocker. Sent every sync so the web keeps
// the flag fresh even if the agent migrated binary↔docker between restarts.
IsDocker bool `json:"isDocker"`
}
// ControlAction represents a server-side control signal for a task.