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
10 changes: 9 additions & 1 deletion engine/podman.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ func buildPodmanRunArgs(opts RunOptions, windowsHostAddress ...string) []string

// ContainerStats returns live CPU and memory stats for a running container.
func (e *PodmanEngine) ContainerStats(name string) (cpu string, cpuPct float64, ram, ramTotal string, ramPct float64, err error) {
if service, ok := hostPodmanServiceClient(); ok {
return service.containerStats(name)
}

cmd := buildCmd("podman", "stats", "--no-stream",
"--format", "{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}", name)
out, runErr := cmd.Output()
Expand Down Expand Up @@ -370,8 +374,12 @@ func (e *PodmanEngine) ContainerStats(name string) (cpu string, cpuPct float64,
}

// ContainerInspect returns status and metadata about a container in one
// Podman process so live dashboards do not need a separate status probe.
// request so live dashboards do not need a separate status probe.
func (e *PodmanEngine) ContainerInspect(name string) (InspectData, error) {
if service, ok := hostPodmanServiceClient(); ok {
return service.containerInspect(name)
}

format := `{{.State.Status}}|{{.State.StartedAt}}|{{.Created}}|{{.RestartCount}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}`
cmd := buildCmd("podman", "inspect", "--format", format, name)
out, err := cmd.Output()
Expand Down
118 changes: 118 additions & 0 deletions engine/podman_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package engine

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

type podmanServiceClient struct {
client *http.Client
baseURL string
}

func (c *podmanServiceClient) get(path string, target any) error {
request, err := http.NewRequestWithContext(processCtx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("creating Podman service request: %w", err)
}
response, err := c.client.Do(request)
if err != nil {
return fmt.Errorf("contacting Podman service: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
detail, _ := io.ReadAll(io.LimitReader(response.Body, maxCommandOutput))
detailText := strings.TrimSpace(string(detail))
if detailText == "" {
detailText = response.Status
}
return fmt.Errorf("Podman service returned %s: %s", response.Status, detailText)
}
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
return fmt.Errorf("decoding Podman service response: %w", err)
}
return nil
}

type podmanServiceInspect struct {
Created string `json:"Created"`
RestartCount int `json:"RestartCount"`
State struct {
Status string `json:"Status"`
StartedAt string `json:"StartedAt"`
Health *struct {
Status string `json:"Status"`
} `json:"Health"`
} `json:"State"`
}

func (c *podmanServiceClient) containerInspect(name string) (InspectData, error) {
var response podmanServiceInspect
path := "/containers/" + url.PathEscape(name) + "/json"
if err := c.get(path, &response); err != nil {
return InspectData{}, fmt.Errorf("podman inspect: %w", err)
}
inspect := InspectData{
Status: response.State.Status,
RestartCount: response.RestartCount,
}
inspect.CreatedAt, _ = time.Parse(time.RFC3339Nano, response.Created)
inspect.StartedAt, _ = time.Parse(time.RFC3339Nano, response.State.StartedAt)
if response.State.Health != nil && response.State.Health.Status != "none" {
inspect.HealthStatus = response.State.Health.Status
}
return inspect, nil
}

type podmanServiceStatsReport struct {
Stats []struct {
CPU float64 `json:"CPU"`
MemUsage uint64 `json:"MemUsage"`
MemLimit uint64 `json:"MemLimit"`
MemPerc float64 `json:"MemPerc"`
} `json:"Stats"`
}

func (c *podmanServiceClient) containerStats(name string) (cpu string, cpuPct float64, ram, ramTotal string, ramPct float64, err error) {
query := url.Values{
"containers": {name},
"stream": {"false"},
}
var response podmanServiceStatsReport
if err := c.get("/v1.0.0/libpod/containers/stats?"+query.Encode(), &response); err != nil {
return "", 0, "", "", 0, fmt.Errorf("podman stats: %w", err)
}
if len(response.Stats) == 0 {
return "", 0, "", "", 0, fmt.Errorf("podman stats: Podman service returned no container statistics")
}
stats := response.Stats[0]
return formatServicePercent(stats.CPU), stats.CPU / 100,
formatServiceBytes(stats.MemUsage), formatServiceBytes(stats.MemLimit), stats.MemPerc / 100, nil
}

func formatServicePercent(value float64) string {
return fmt.Sprintf("%.2f%%", value)
}

func formatServiceBytes(value uint64) string {
const unit = uint64(1024)
if value < unit {
return fmt.Sprintf("%dB", value)
}
units := [...]string{"KiB", "MiB", "GiB", "TiB", "PiB"}
amount := float64(value)
for _, suffix := range units {
amount /= float64(unit)
if amount < float64(unit) || suffix == units[len(units)-1] {
formatted := fmt.Sprintf("%.1f", amount)
formatted = strings.TrimSuffix(formatted, ".0")
return formatted + suffix
}
}
return fmt.Sprintf("%dB", value)
}
40 changes: 40 additions & 0 deletions engine/podman_service_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//go:build darwin

package engine

import (
"context"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)

const podmanServiceRequestTimeout = 2500 * time.Millisecond

func newUnixPodmanServiceClient(socketPath string) *podmanServiceClient {
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", socketPath)
},
}
return &podmanServiceClient{
client: &http.Client{Transport: transport, Timeout: podmanServiceRequestTimeout},
baseURL: "http://podman",
}
}

var darwinPodmanServiceClient = sync.OnceValue(func() *podmanServiceClient {
socket := filepath.Join(os.TempDir(), "podman", OmnideckMachineName+"-api.sock")
return newUnixPodmanServiceClient(socket)
})

// hostPodmanServiceClient keeps periodic dashboard reads inside this process.
// Terminal.app tracks short-lived descendants even when they have their own
// session, so launching `podman` every poll still changes the terminal title.
func hostPodmanServiceClient() (*podmanServiceClient, bool) {
return darwinPodmanServiceClient(), true
}
7 changes: 7 additions & 0 deletions engine/podman_service_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !darwin

package engine

func hostPodmanServiceClient() (*podmanServiceClient, bool) {
return nil, false
}
77 changes: 77 additions & 0 deletions engine/podman_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package engine

import (
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestPodmanServiceContainerInspect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/omnideck/json" {
t.Fatalf("request path = %q", r.URL.Path)
}
fmt.Fprint(w, `{
"Created":"2026-08-13T20:00:00.123456789Z",
"RestartCount":2,
"State":{
"Status":"running",
"StartedAt":"2026-08-13T20:01:00.987654321Z",
"Health":{"Status":"healthy"}
}
}`)
}))
defer server.Close()

client := &podmanServiceClient{client: server.Client(), baseURL: server.URL}
inspect, err := client.containerInspect("omnideck")
if err != nil {
t.Fatal(err)
}
if inspect.Status != "running" || inspect.RestartCount != 2 || inspect.HealthStatus != "healthy" {
t.Fatalf("unexpected inspect data: %+v", inspect)
}
if want := time.Date(2026, 8, 13, 20, 0, 0, 123456789, time.UTC); !inspect.CreatedAt.Equal(want) {
t.Fatalf("created = %s, want %s", inspect.CreatedAt, want)
}
if want := time.Date(2026, 8, 13, 20, 1, 0, 987654321, time.UTC); !inspect.StartedAt.Equal(want) {
t.Fatalf("started = %s, want %s", inspect.StartedAt, want)
}
}

func TestPodmanServiceContainerStats(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1.0.0/libpod/containers/stats" {
t.Fatalf("request path = %q", r.URL.Path)
}
if r.URL.Query().Get("containers") != "omnideck" || r.URL.Query().Get("stream") != "false" {
t.Fatalf("unexpected query: %s", r.URL.RawQuery)
}
fmt.Fprint(w, `{"Error":null,"Stats":[{"CPU":0.1800404,"MemUsage":112783360,"MemLimit":2147483648,"MemPerc":5.251884}]}`)
}))
defer server.Close()

client := &podmanServiceClient{client: server.Client(), baseURL: server.URL}
cpu, cpuPct, ram, ramTotal, ramPct, err := client.containerStats("omnideck")
if err != nil {
t.Fatal(err)
}
if cpu != "0.18%" || math.Abs(cpuPct-0.001800404) > 1e-12 || ram != "107.6MiB" || ramTotal != "2GiB" || math.Abs(ramPct-0.05251884) > 1e-12 {
t.Fatalf("unexpected stats: cpu=%q cpuPct=%v ram=%q total=%q ramPct=%v", cpu, cpuPct, ram, ramTotal, ramPct)
}
}

func TestPodmanServiceReportsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"cause":"no such container"}`, http.StatusNotFound)
}))
defer server.Close()

client := &podmanServiceClient{client: server.Client(), baseURL: server.URL}
if _, err := client.containerInspect("missing"); err == nil {
t.Fatal("expected service error")
}
}
11 changes: 6 additions & 5 deletions engine/process_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import (
"syscall"
)

// prepareHiddenConsoleCommand keeps non-interactive helpers out of the TUI's
// foreground process group. Terminal.app includes every process in that group
// in its title, so the dashboard's frequent Podman probes would otherwise make
// the title alternate between "podman — omnideck" and "omnideck".
// prepareHiddenConsoleCommand starts non-interactive helpers in a new session.
// A separate process group is not enough: the child still belongs to the TUI's
// terminal session, so Terminal.app can expose its name as the active process
// while the dashboard polls Podman. A new session has no controlling terminal
// and therefore cannot replace Omnideck in the terminal title.
func prepareHiddenConsoleCommand(command *exec.Cmd) {
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
command.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}

func prepareVisibleCommand(command *exec.Cmd) {
Expand Down
11 changes: 7 additions & 4 deletions engine/process_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import (
"testing"
)

func TestPrepareHiddenConsoleCommandUsesSeparateProcessGroup(t *testing.T) {
func TestPrepareHiddenConsoleCommandUsesSeparateSession(t *testing.T) {
command := exec.Command("podman", "info")
prepareHiddenConsoleCommand(command)
if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid {
t.Fatalf("macOS background helpers must use a separate process group, got %#v", command.SysProcAttr)
if command.SysProcAttr == nil || !command.SysProcAttr.Setsid {
t.Fatalf("macOS background helpers must use a separate session, got %#v", command.SysProcAttr)
}
if command.SysProcAttr.Setpgid {
t.Fatalf("macOS background helpers must not combine setsid and setpgid, got %#v", command.SysProcAttr)
}

prepareVisibleCommand(command)
if command.SysProcAttr != nil {
t.Fatalf("visible macOS helpers must remain in the foreground process group, got %#v", command.SysProcAttr)
t.Fatalf("visible macOS helpers must remain in the terminal session, got %#v", command.SysProcAttr)
}
}