Skip to content

Commit b56a7df

Browse files
committed
Feat: Add docker endpoint resolution from config to compose runtime
1 parent 8608f18 commit b56a7df

1 file changed

Lines changed: 130 additions & 9 deletions

File tree

compute-agent/internal/runtime/compose.go

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,30 @@ import (
1010
"path/filepath"
1111
"sort"
1212
"strings"
13+
"time"
1314

15+
"github.com/docker/docker/api/types"
16+
"github.com/docker/docker/client"
1417
"github.com/persys-dev/persys-cloud/compute-agent/pkg/models"
1518
"github.com/sirupsen/logrus"
1619
)
1720

1821
// ComposeRuntime manages Docker Compose workloads
1922
type ComposeRuntime struct {
20-
composeBinary string
21-
workDir string
22-
logger *logrus.Entry
23+
composeBinary string
24+
dockerEndpoint string
25+
workDir string
26+
logger *logrus.Entry
27+
28+
// dockerClient is used only for per-container stats collection (UsageStats).
29+
// It's optional - if it fails to initialize, UsageStats simply reports
30+
// unavailable and every other compose operation (which shells out to the
31+
// docker/compose CLIs) is unaffected.
32+
dockerClient *client.Client
2333
}
2434

2535
// NewComposeRuntime creates a new Docker Compose runtime
26-
func NewComposeRuntime(composeBinary, workDir string, logger *logrus.Logger) (*ComposeRuntime, error) {
36+
func NewComposeRuntime(composeBinary, dockerEndpoint, workDir string, logger *logrus.Logger) (*ComposeRuntime, error) {
2737
if composeBinary == "" {
2838
composeBinary = "docker compose"
2939
}
@@ -43,10 +53,33 @@ func NewComposeRuntime(composeBinary, workDir string, logger *logrus.Logger) (*C
4353
return nil, fmt.Errorf("docker compose binary not found: %w", err)
4454
}
4555

56+
// Best-effort docker client for stats collection. Compose itself doesn't need
57+
// this (it drives the compose/docker CLIs directly), so a failure here is
58+
// logged but non-fatal - it only means UsageStats will be unavailable.
59+
var dockerCli *client.Client
60+
var err error
61+
if dockerEndpoint != "" {
62+
dockerCli, err = client.NewClientWithOpts(
63+
client.WithHost(dockerEndpoint),
64+
client.WithAPIVersionNegotiation(),
65+
)
66+
} else {
67+
dockerCli, err = client.NewClientWithOpts(
68+
client.FromEnv,
69+
client.WithAPIVersionNegotiation(),
70+
)
71+
}
72+
if err != nil {
73+
logger.WithError(err).Warn("compose runtime: failed to init docker client, per-workload metrics will be unavailable")
74+
dockerCli = nil
75+
}
76+
4677
return &ComposeRuntime{
47-
composeBinary: composeBinary,
48-
workDir: workDir,
49-
logger: logger.WithField("runtime", "compose"),
78+
composeBinary: composeBinary,
79+
dockerEndpoint: dockerEndpoint,
80+
workDir: workDir,
81+
logger: logger.WithField("runtime", "compose"),
82+
dockerClient: dockerCli,
5083
}, nil
5184
}
5285

@@ -232,12 +265,100 @@ func (c *ComposeRuntime) Healthy(ctx context.Context) error {
232265
return nil
233266
}
234267

268+
// UsageStats returns an aggregated point-in-time resource usage snapshot for
269+
// a compose project, implementing runtime.UsageStatsProvider. It sums the
270+
// per-container stats (Docker's one-shot stats endpoint, same as
271+
// DockerRuntime.UsageStats) across every currently-running container that
272+
// belongs to the project.
273+
func (c *ComposeRuntime) UsageStats(ctx context.Context, id string) (*models.WorkloadUsage, error) {
274+
if c.dockerClient == nil {
275+
return nil, fmt.Errorf("docker client unavailable for compose stats")
276+
}
277+
278+
projectDir := filepath.Join(c.workDir, id)
279+
runningIDs, err := c.composeContainerIDs(ctx, projectDir, id, "ps", "-q", "--status", "running")
280+
if err != nil {
281+
c.logger.Debugf("compose usage query failed for %s via compose CLI, falling back to docker labels: %v", id, err)
282+
runningIDs, err = c.dockerContainerIDsByComposeProject(ctx, id, false)
283+
if err != nil {
284+
return nil, fmt.Errorf("failed to get running compose container list: %w", err)
285+
}
286+
}
287+
288+
if len(runningIDs) == 0 {
289+
return nil, fmt.Errorf("no running containers for compose project: %s", id)
290+
}
291+
292+
usage := &models.WorkloadUsage{
293+
WorkloadID: id,
294+
Type: string(models.WorkloadTypeCompose),
295+
CollectedAt: time.Now(),
296+
Source: "compose.stats",
297+
}
298+
299+
var cpuTotal float64
300+
var sampled int
301+
for _, containerID := range runningIDs {
302+
stats, err := c.dockerClient.ContainerStatsOneShot(ctx, containerID)
303+
if err != nil {
304+
c.logger.Debugf("failed to fetch stats for compose container %s (project %s): %v", containerID, id, err)
305+
continue
306+
}
307+
308+
var raw types.StatsJSON
309+
decodeErr := json.NewDecoder(stats.Body).Decode(&raw)
310+
stats.Body.Close()
311+
if decodeErr != nil {
312+
c.logger.Debugf("failed to decode stats for compose container %s (project %s): %v", containerID, id, decodeErr)
313+
continue
314+
}
315+
316+
cpuTotal += dockerCPUPercent(&raw)
317+
usage.MemoryBytes += int64(dockerMemoryUsageNoCache(&raw.MemoryStats))
318+
319+
for _, netStats := range raw.Networks {
320+
usage.NetRXBytes += int64(netStats.RxBytes)
321+
usage.NetTXBytes += int64(netStats.TxBytes)
322+
}
323+
324+
for _, entry := range raw.BlkioStats.IoServiceBytesRecursive {
325+
switch strings.ToLower(entry.Op) {
326+
case "read":
327+
usage.DiskReadBytes += int64(entry.Value)
328+
case "write":
329+
usage.DiskWriteBytes += int64(entry.Value)
330+
}
331+
}
332+
333+
sampled++
334+
}
335+
336+
if sampled == 0 {
337+
return nil, fmt.Errorf("failed to sample stats for any container in compose project: %s", id)
338+
}
339+
340+
usage.CPUPercent = cpuTotal
341+
return usage, nil
342+
}
343+
235344
// Helper functions
236345

346+
// buildCommand creates an exec.Cmd for docker compose with proper Docker endpoint support
237347
func (c *ComposeRuntime) buildCommand(ctx context.Context, args ...string) *exec.Cmd {
238348
parts := strings.Fields(c.composeBinary)
239349
allArgs := append(parts[1:], args...)
240-
return exec.CommandContext(ctx, parts[0], allArgs...)
350+
351+
cmd := exec.CommandContext(ctx, parts[0], allArgs...)
352+
353+
// CRITICAL: Pass the correct Docker socket to every command
354+
if c.dockerEndpoint != "" {
355+
// Preserve existing environment + override DOCKER_HOST
356+
env := os.Environ()
357+
env = append(env, "DOCKER_HOST="+c.dockerEndpoint)
358+
cmd.Env = env
359+
}
360+
361+
return cmd
241362
}
242363

243364
func (c *ComposeRuntime) parseSpec(specMap map[string]interface{}) (*models.ComposeSpec, error) {
@@ -341,4 +462,4 @@ func (c *ComposeRuntime) dockerContainerIDsByComposeProject(ctx context.Context,
341462
}
342463
sort.Strings(ids)
343464
return ids, nil
344-
}
465+
}

0 commit comments

Comments
 (0)