diff --git a/Dockerfile b/Dockerfile index 200da0f..3707b62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index 9ef9c54..6d2658b 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -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 diff --git a/internal/agent/docker.go b/internal/agent/docker.go new file mode 100644 index 0000000..de5189d --- /dev/null +++ b/internal/agent/docker.go @@ -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 +} diff --git a/internal/agent/sync.go b/internal/agent/sync.go index f4e584e..8e88344 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -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() diff --git a/internal/agent/types.go b/internal/agent/types.go index ea7a51b..e1f2fe8 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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.