Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ docktail
*.out
coverage.txt

# GoReleaser output
dist/

# IDE files
.vscode/
.idea/
Expand Down
34 changes: 33 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,44 @@ jobs:
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
- runner: ubuntu-latest
platform: linux/arm/v7
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up QEMU
if: matrix.platform == 'linux/arm/v7'
uses: docker/setup-qemu-action@v3
with:
platforms: arm

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build Docker image
run: docker build .
run: docker buildx build --platform ${{ matrix.platform }} .

# Builds the release archives the way release.yaml does, without
# publishing, so a broken .goreleaser.yaml fails here rather than on release.
release-snapshot:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'

- name: Build release archives (snapshot)
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: release --snapshot --clean --skip=publish

# Regression test for issues #72 and #78: a replaced tailscaled socket directory
# leaves DockTail's bind mount stale forever. Needs no tailnet credentials, so
Expand Down
18 changes: 15 additions & 3 deletions .github/workflows/docker-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,22 @@ jobs:
- runner: ubuntu-24.04-arm
platform: linux/arm64
arch: arm64
# No 32-bit ARM runner: the Go binary is cross-compiled on the build
# platform, so QEMU only runs the runtime stage's package install.
- runner: ubuntu-latest
platform: linux/arm/v7
arch: armv7

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up QEMU
if: matrix.arch == 'armv7'
uses: docker/setup-qemu-action@v3
with:
platforms: arm

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

Expand Down Expand Up @@ -85,8 +96,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}

manifest:
needs: build
Expand Down Expand Up @@ -133,5 +144,6 @@ jobs:
for TAG in $TAGS; do
docker buildx imagetools create -t $TAG \
$TAG-amd64 \
$TAG-arm64
$TAG-arm64 \
$TAG-armv7
done
52 changes: 52 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Release Binaries

# Attaches static Linux binaries to a GitHub release once it is published. Tags
# alone only produce images (docker-build.yaml); binaries follow the releases
# the maintainer publishes, and the release's name and notes are left as written.
# Run it by hand to (re)attach binaries to an existing release whose tag already
# contains .goreleaser.yaml.
on:
release:
types:
- published
workflow_dispatch:
inputs:
tag:
description: Tag of an existing GitHub release
required: true

jobs:
binaries:
runs-on: ubuntu-latest
permissions:
contents: write
env:
TAG: ${{ github.event.release.tag_name || inputs.tag }}

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.TAG }}
fetch-depth: 0
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'

- name: Build release archives
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: release --clean --skip=publish
env:
GORELEASER_CURRENT_TAG: ${{ env.TAG }}

- name: Attach archives to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$TAG" dist/*.tar.gz dist/checksums.txt \
--repo "$GITHUB_REPOSITORY" --clobber
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,6 @@ go.work.sum

# Built Visual Studio Code Extensions
*.vsix

# GoReleaser output
dist/
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ version: "2"
linters:
default: standard
enable:
- gosec
- misspell
51 changes: 51 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Static release binaries, attached to each published GitHub release by
# .github/workflows/release.yaml. GoReleaser only builds and packages; the
# workflow uploads the archives to the release the maintainer published, so the
# release name and notes stay as written.
version: 2

project_name: docktail

builds:
- id: docktail
main: .
binary: docktail
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
- arm
goarm:
- "7"
flags:
- -trimpath
# Same version stamp as the image (Dockerfile VERSION build arg = the tag);
# a snapshot build is stamped with its snapshot version instead.
ldflags:
- -s -w -X github.com/marvinvr/docktail/version.Version={{ if .IsSnapshot }}{{ .Version }}{{ else }}{{ .Tag }}{{ end }}

archives:
- id: docktail
formats:
- tar.gz
# docktail_1.9.0_linux_amd64.tar.gz, …_linux_arm64, …_linux_armv7
name_template: >-
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}
files:
- LICENSE
- README.md

checksum:
name_template: checksums.txt

snapshot:
version_template: "{{ incpatch .Version }}-snapshot"

changelog:
disable: true

release:
disable: true
25 changes: 18 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# Build stage
FROM golang:1.25-alpine AS builder
# Build stage. Runs on the build machine's own platform and cross-compiles for
# the target (CGO is off), so a multi-arch build only emulates the small
# runtime stage instead of the whole Go toolchain.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder

ARG VERSION=dev
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT

WORKDIR /build

Expand All @@ -15,18 +20,22 @@ RUN go mod download
# Copy source code
COPY . .

# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
# Build the application. TARGETVARIANT is "v7" for linux/arm/v7; GOARM wants
# the bare number.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \
go build -a -installsuffix cgo \
-ldflags "-w -s -X github.com/marvinvr/docktail/version.Version=${VERSION}" \
-o docktail .

# Tailscale binary stage — ensures CLI version matches the sidecar daemon exactly
FROM tailscale/tailscale:latest AS tailscale

# Runtime stage
# Runtime stage. DockTail runs no tailscaled of its own; it only drives the
# host's or sidecar's daemon through the tailscale CLI over the mounted socket,
# so no packet-filtering tools are needed here.
FROM alpine:latest

RUN apk add --no-cache ca-certificates iptables ip6tables
RUN apk add --no-cache ca-certificates

# Copy tailscale CLI from official image to guarantee version consistency with sidecar
COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscale
Expand All @@ -41,4 +50,6 @@ COPY --from=builder /build/docktail .
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/app/docktail", "health"]

ENTRYPOINT ["/bin/sh", "-c", "sleep 1 && exec /app/docktail"]
# Exec form: DockTail is PID 1 and receives SIGTERM directly. It waits for a
# tailscaled that is still starting on its own (see main.go socketStartupWait).
ENTRYPOINT ["/app/docktail"]
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ go build -o docktail .
docker build -t docktail:latest .
```

The image is published for `linux/amd64`, `linux/arm64` and `linux/arm/v7`, and [releases](https://github.com/marvinvr/docktail/releases) newer than 1.8.3 carry static Linux binaries for the same platforms; see [Platforms And Release Binaries](docs/02-installation.md#platforms-and-release-binaries).

## Links

- [Tailscale Services Documentation](https://tailscale.com/kb/1552/tailscale-services)
Expand Down
20 changes: 15 additions & 5 deletions cloud/hostfs_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cloud

import (
"bufio"
"math"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -212,13 +213,13 @@ func statfsBytes(path string) (proto.Filesystem, bool) {
if err := syscall.Statfs(path, &st); err != nil {
return proto.Filesystem{}, false
}
bs := uint64(st.Bsize)
if bs == 0 || uint64(st.Blocks) == 0 {
if st.Bsize <= 0 || uint64(st.Blocks) == 0 {
return proto.Filesystem{}, false
}
total := int64(uint64(st.Blocks) * bs)
free := int64(uint64(st.Bfree) * bs)
avail := int64(uint64(st.Bavail) * bs)
bs := uint64(st.Bsize)
total := saturatingInt64(uint64(st.Blocks) * bs)
free := saturatingInt64(uint64(st.Bfree) * bs)
avail := saturatingInt64(uint64(st.Bavail) * bs)
if total <= 0 {
return proto.Filesystem{}, false
}
Expand All @@ -232,6 +233,15 @@ func statfsBytes(path string) (proto.Filesystem, bool) {
return proto.Filesystem{TotalBytes: total, UsedBytes: used, AvailBytes: avail}, true
}

// saturatingInt64 converts a byte count to the int64 the report carries,
// capping it instead of wrapping to a negative value.
func saturatingInt64(u uint64) int64 {
if u > math.MaxInt64 {
return math.MaxInt64
}
return int64(u)
}

// usedFraction is the `df` reading — used of what a normal user can still fill.
func usedFraction(fs proto.Filesystem) float64 {
if denom := fs.UsedBytes + fs.AvailBytes; denom > 0 {
Expand Down
8 changes: 4 additions & 4 deletions cloud/hostmetrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ func readThermalZones() []proto.TempReading {
continue
}
label := filepath.Base(d)
if t, err := os.ReadFile(filepath.Join(d, "type")); err == nil {
if t, err := os.ReadFile(filepath.Join(d, "type")); err == nil { //nolint:gosec // G304: sysfs path globbed under sysDir
if s := strings.TrimSpace(string(t)); s != "" {
label = s
}
Expand All @@ -362,7 +362,7 @@ func readHwmonTemps() []proto.TempReading {
// readMilliCelsius reads a sysfs millidegree-Celsius file and returns degrees C
// rounded to one decimal, dropping implausible values.
func readMilliCelsius(path string) (float64, bool) {
raw, err := os.ReadFile(path)
raw, err := os.ReadFile(path) //nolint:gosec // G304: sysfs path globbed under sysDir
if err != nil {
return 0, false
}
Expand All @@ -384,10 +384,10 @@ func hwmonLabel(inputPath string) string {
dir := filepath.Dir(inputPath)
prefix := strings.TrimSuffix(filepath.Base(inputPath), "_input") // tempX
var chip, label string
if n, err := os.ReadFile(filepath.Join(dir, "name")); err == nil {
if n, err := os.ReadFile(filepath.Join(dir, "name")); err == nil { //nolint:gosec // G304: sysfs path globbed under sysDir
chip = strings.TrimSpace(string(n))
}
if l, err := os.ReadFile(filepath.Join(dir, prefix+"_label")); err == nil {
if l, err := os.ReadFile(filepath.Join(dir, prefix+"_label")); err == nil { //nolint:gosec // G304: sysfs path globbed under sysDir
label = strings.TrimSpace(string(l))
}
switch {
Expand Down
2 changes: 1 addition & 1 deletion cloud/wsclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ type backoff struct {
}

func newBackoff() *backoff {
return &backoff{rng: rand.New(rand.NewSource(time.Now().UnixNano()))}
return &backoff{rng: rand.New(rand.NewSource(time.Now().UnixNano()))} //nolint:gosec // G404: reconnect jitter only spreads agents apart; it is not a secret
}

func (b *backoff) next() time.Duration {
Expand Down
18 changes: 14 additions & 4 deletions docker/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -513,7 +514,7 @@ func (c *Client) ContainerStats(ctx context.Context, containerID string) (Contai
CPUSystemUsage: v.CPUStats.SystemUsage,
OnlineCPUs: onlineCPUs,
MemUsageBytes: memUsageNoCache(v.MemoryStats),
MemLimitBytes: int64(v.MemoryStats.Limit),
MemLimitBytes: saturatingInt64(v.MemoryStats.Limit),
}, nil
}

Expand All @@ -523,12 +524,21 @@ func (c *Client) ContainerStats(ctx context.Context, containerID string) (Contai
// back to the raw usage when neither is present.
func memUsageNoCache(mem container.MemoryStats) int64 {
if v, ok := mem.Stats["total_inactive_file"]; ok && v < mem.Usage { // cgroup v1
return int64(mem.Usage - v)
return saturatingInt64(mem.Usage - v)
}
if v, ok := mem.Stats["inactive_file"]; ok && v < mem.Usage { // cgroup v2
return int64(mem.Usage - v)
return saturatingInt64(mem.Usage - v)
}
return int64(mem.Usage)
return saturatingInt64(mem.Usage)
}

// saturatingInt64 converts a byte count from the Docker API (uint64) to the int64 the
// cloud report carries, saturating instead of wrapping to a negative value.
func saturatingInt64(u uint64) int64 {
if u > math.MaxInt64 {
return math.MaxInt64
}
return int64(u)
}

// ContainerLogsTail returns the last n log lines of a container plus the total
Expand Down
Loading
Loading