GitRoot

craft your forge, build your project, grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package exec
  6
  7import (
  8	"bytes"
  9	"os/exec"
 10	"path/filepath"
 11	"sync"
 12	"time"
 13
 14	"github.com/samber/oops"
 15	"github.com/shirou/gopsutil/v4/process"
 16	pluginLib "gitroot.dev/libs/golang/plugin/model"
 17	"gitroot.dev/server/logger"
 18)
 19
 20const watchTime = 100 * time.Millisecond
 21
 22type BareMetal struct {
 23	logger *logger.Logger
 24}
 25
 26func NewBareMetal(log *logger.Logger) *BareMetal {
 27	return &BareMetal{
 28		logger: log.NewSubLogger("bareMetal"),
 29	}
 30}
 31
 32func (e *BareMetal) name() string {
 33	return "bareMetal"
 34}
 35
 36func (e *BareMetal) exec(dir string, exe pluginLib.Exec) (*pluginLib.ExecStatus, error) {
 37	status := &pluginLib.ExecStatus{
 38		CmdsExec:   make([]string, len(exe.Cmds)),
 39		CmdsStatus: make([]int, len(exe.Cmds)),
 40		CmdsLogs:   make([]string, len(exe.Cmds)),
 41		CmdsStats:  make([]pluginLib.CmdStats, len(exe.Cmds)),
 42	}
 43
 44	for i, cmd := range exe.Cmds {
 45		var logs bytes.Buffer
 46		c := exec.Command(cmd.Cmd, cmd.Args...)
 47		e.logger.Debug("exec a cmd", logger.NewLoggerPair("cmd", c.String()))
 48		c.Env = exe.Env
 49		c.Dir = filepath.Join(dir, APP_DIR)
 50		c.Stdout = &logs
 51		c.Stderr = &logs
 52		stats := e.start(c)
 53		status.CmdsExec[i] = c.String()
 54		status.CmdsLogs[i] = logs.String()
 55		status.CmdsStatus[i] = c.ProcessState.ExitCode()
 56		status.CmdsStats[i] = *stats
 57	}
 58	return status, nil
 59}
 60
 61func (e *BareMetal) start(cmd *exec.Cmd) *pluginLib.CmdStats {
 62	stats := &pluginLib.CmdStats{}
 63	var wg sync.WaitGroup
 64
 65	if err := cmd.Start(); err != nil {
 66		e.logger.Error("can't start process", err)
 67		return stats
 68	}
 69	proc, err := process.NewProcess(int32(cmd.Process.Pid))
 70	if err != nil {
 71		e.logger.Error("can't get process", err)
 72		return stats
 73	}
 74
 75	wg.Go(func() {
 76		if err := e.monitor(proc, stats); err != nil {
 77			e.logger.Error("monitor not started", err)
 78		}
 79	})
 80
 81	cmd.Wait() //don't look at error because it will incorpored in statusCode and logs
 82	wg.Wait()
 83
 84	return stats
 85}
 86
 87func (e *BareMetal) monitor(proc *process.Process, stats *pluginLib.CmdStats) error {
 88	ticker := time.NewTicker(watchTime)
 89	defer ticker.Stop()
 90
 91	initialIOCounters, err := proc.IOCounters()
 92	if err != nil {
 93		return oops.Wrapf(err, "can't read first io counter")
 94	}
 95	e.updateStats(proc, stats, initialIOCounters)
 96
 97	for range ticker.C {
 98		isRunning, err := proc.IsRunning()
 99		if err != nil || !isRunning {
100			e.updateStats(proc, stats, initialIOCounters)
101			return err
102		}
103		e.updateStats(proc, stats, initialIOCounters)
104	}
105
106	e.updateStats(proc, stats, initialIOCounters)
107	return nil
108}
109
110func (e *BareMetal) updateStats(proc *process.Process, stats *pluginLib.CmdStats, initialIOCounters *process.IOCountersStat) {
111	if memInfo, err := proc.MemoryInfo(); err == nil {
112		if memInfo.RSS > stats.MaxMemoryBytes {
113			stats.MaxMemoryBytes = memInfo.RSS
114		}
115	}
116
117	if threads, err := proc.NumThreads(); err == nil {
118		if threads > stats.MaxThreads {
119			stats.MaxThreads = threads
120		}
121	}
122
123	if currentIOCounters, err := proc.IOCounters(); err == nil {
124		stats.ReadBytesTotal = currentIOCounters.ReadBytes - initialIOCounters.ReadBytes
125		stats.WriteBytesTotal = currentIOCounters.WriteBytes - initialIOCounters.WriteBytes
126	}
127
128	if cpuTimes, err := proc.Times(); err == nil {
129		stats.TotalCPUTimeMs = uint64((cpuTimes.User + cpuTimes.System) * 1000)
130	}
131}