@@ -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
1922type 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
237347func (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
243364func (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