Clarify process CPU and improve Docker actions

This commit is contained in:
Maxim
2026-08-21 21:58:00 +03:00
parent f6498a2fc1
commit f764195ba5
6 changed files with 113 additions and 32 deletions

View File

@@ -41,18 +41,22 @@ type agentConfig struct {
} }
type AgentContainer struct { type AgentContainer struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Image string `json:"image"` Image string `json:"image"`
State string `json:"state"` State string `json:"state"`
Status string `json:"status"` Status string `json:"status"`
Health string `json:"health,omitempty"` Health string `json:"health,omitempty"`
Restarts int64 `json:"restarts"` Restarts int64 `json:"restarts"`
CPUPercent float64 `json:"cpuPercent"` CPUPercent float64 `json:"cpuPercent"`
MemoryUsage uint64 `json:"memoryUsage"` MemoryUsage uint64 `json:"memoryUsage"`
Ports []string `json:"ports,omitempty"` Ports []string `json:"ports,omitempty"`
Compose bool `json:"compose"` Compose bool `json:"compose"`
UpdateAvailable bool `json:"updateAvailable"` UpdateAvailable bool `json:"updateAvailable"`
CurrentVersion string `json:"currentVersion,omitempty"`
CurrentDigest string `json:"currentDigest,omitempty"`
AvailableVersion string `json:"availableVersion,omitempty"`
AvailableDigest string `json:"availableDigest,omitempty"`
} }
type AgentInterface struct { type AgentInterface struct {
@@ -99,6 +103,7 @@ type AgentReport struct {
UptimeSeconds int64 `json:"uptimeSeconds"` UptimeSeconds int64 `json:"uptimeSeconds"`
Load1 float64 `json:"load1"` Load1 float64 `json:"load1"`
CPUPercent float64 `json:"cpuPercent"` CPUPercent float64 `json:"cpuPercent"`
CPUCount int `json:"cpuCount"`
MemoryTotal uint64 `json:"memoryTotal"` MemoryTotal uint64 `json:"memoryTotal"`
MemoryUsed uint64 `json:"memoryUsed"` MemoryUsed uint64 `json:"memoryUsed"`
SwapTotal uint64 `json:"swapTotal"` SwapTotal uint64 `json:"swapTotal"`
@@ -394,7 +399,9 @@ func runGuestAgent(opts agentOptions) error {
if requestErr != nil { if requestErr != nil {
fmt.Fprintf(os.Stderr, "agent: %v\n", requestErr) fmt.Fprintf(os.Stderr, "agent: %v\n", requestErr)
} }
time.Sleep(10 * time.Second) // Короткий интервал нужен для интерактивных команд из панели. Тяжёлый
// системный инвентарь отдельно кэшируется и не запускается каждые 3 секунды.
time.Sleep(3 * time.Second)
} }
} }
@@ -530,7 +537,7 @@ func saveAgentConfig(path string, c agentConfig) error {
func collectAgentReport() AgentReport { func collectAgentReport() AgentReport {
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
machineID, _ := os.ReadFile("/etc/machine-id") machineID, _ := os.ReadFile("/etc/machine-id")
report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CollectedAt: time.Now().Unix()} report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCount: runtime.NumCPU(), CollectedAt: time.Now().Unix()}
if data, err := os.ReadFile("/proc/uptime"); err == nil { if data, err := os.ReadFile("/proc/uptime"); err == nil {
fields := strings.Fields(string(data)) fields := strings.Fields(string(data))
if len(fields) > 0 { if len(fields) > 0 {
@@ -929,14 +936,10 @@ func collectDockerContainers() ([]AgentContainer, string) {
item.Health = detail.State.Health.Status item.Health = detail.State.Health.Status
} }
item.Compose = detail.Config.Labels["com.docker.compose.service"] != "" item.Compose = detail.Config.Labels["com.docker.compose.service"] != ""
imageResponse, imageErr := client.Get("http://docker/images/" + url.PathEscape(v.Image) + "/json") item.CurrentVersion, item.CurrentDigest, _ = inspectDockerImage(client, detail.Image, v.Image)
if imageErr == nil { var availableID string
var imageDetail struct{ ID string } item.AvailableVersion, item.AvailableDigest, availableID = inspectDockerImage(client, v.Image, v.Image)
if json.NewDecoder(imageResponse.Body).Decode(&imageDetail) == nil && detail.Image != "" && imageDetail.ID != "" { item.UpdateAvailable = detail.Image != "" && availableID != "" && detail.Image != availableID
item.UpdateAvailable = detail.Image != imageDetail.ID
}
imageResponse.Body.Close()
}
} }
inspect.Body.Close() inspect.Body.Close()
} }
@@ -987,6 +990,46 @@ func collectDockerContainers() ([]AgentContainer, string) {
} }
return result, "" return result, ""
} }
func inspectDockerImage(client *http.Client, reference, fallback string) (string, string, string) {
if reference == "" {
return "", "", ""
}
response, err := client.Get("http://docker/images/" + url.PathEscape(reference) + "/json")
if err != nil {
return "", "", ""
}
defer response.Body.Close()
var detail struct {
ID string
RepoTags []string
RepoDigests []string
Config struct{ Labels map[string]string }
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&detail) != nil {
return "", "", ""
}
version := firstNonEmpty(detail.Config.Labels["org.opencontainers.image.version"], detail.Config.Labels["version"], firstString(detail.RepoTags), fallback, shortDockerDigest(detail.ID))
digest := ""
if len(detail.RepoDigests) > 0 {
digest = detail.RepoDigests[0]
if _, value, found := strings.Cut(digest, "@"); found {
digest = value
}
}
if digest == "" {
digest = detail.ID
}
return version, shortDockerDigest(digest), detail.ID
}
func shortDockerDigest(value string) string {
value = strings.TrimPrefix(value, "sha256:")
if len(value) > 12 {
value = value[:12]
}
return value
}
func firstString(v []string) string { func firstString(v []string) string {
if len(v) > 0 { if len(v) > 0 {
return v[0] return v[0]

View File

@@ -63,10 +63,10 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
} }
} }
} }
case "docker-restart", "docker-check", "docker-update": case "docker-restart", "docker-check", "docker-update", "docker-remove":
if safeContainerName.MatchString(target) { if safeContainerName.MatchString(target) {
for _, container := range report.Containers { for _, container := range report.Containers {
if container.ID == target || container.Name == target { if (container.ID == target || container.Name == target) && (action != "docker-remove" || container.State != "running") {
valid = true valid = true
break break
} }
@@ -168,6 +168,8 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
output = updateAgentContainer(command.Target, true) output = updateAgentContainer(command.Target, true)
case "docker-check": case "docker-check":
output = updateAgentContainer(command.Target, false) output = updateAgentContainer(command.Target, false)
case "docker-remove":
output = removeAgentContainer(command.Target)
case "system-update": case "system-update":
output = updateAgentSystem() output = updateAgentSystem()
case "updates-refresh": case "updates-refresh":
@@ -189,6 +191,24 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
return result return result
} }
func removeAgentContainer(target string) string {
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
}}
defer transport.CloseIdleConnections()
request, _ := http.NewRequest(http.MethodDelete, "http://docker/containers/"+target+"?v=false&force=false", nil)
response, err := (&http.Client{Transport: transport, Timeout: 20 * time.Second}).Do(request)
if err != nil {
return "ERROR: " + err.Error()
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
message, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return fmt.Sprintf("ERROR: Docker вернул %s: %s", response.Status, strings.TrimSpace(string(message)))
}
return "Остановленный контейнер удалён; volumes сохранены"
}
func updateAgentSystem() string { func updateAgentSystem() string {
if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") { if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") {
return output return output

View File

@@ -12,7 +12,7 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer store.Close() defer store.Close()
report, _ := json.Marshal(AgentReport{Services: []AgentService{{Name: "gitea.service"}}, Containers: []AgentContainer{{ID: "abc123", Name: "gitea"}}}) report, _ := json.Marshal(AgentReport{Services: []AgentService{{Name: "gitea.service"}}, Containers: []AgentContainer{{ID: "abc123", Name: "gitea", State: "running"}, {ID: "old123", Name: "old", State: "exited"}}})
_, err = store.db.Exec(`INSERT INTO agents(id,name,secret_hash,enrolled_at,last_seen,report_json) VALUES('a','agent','x',?,?,?)`, time.Now().Unix(), time.Now().Unix(), string(report)) _, err = store.db.Exec(`INSERT INTO agents(id,name,secret_hash,enrolled_at,last_seen,report_json) VALUES('a','agent','x',?,?,?)`, time.Now().Unix(), time.Now().Unix(), string(report))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -26,9 +26,12 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
if _, err = store.QueueAgentAction("a", "system-update", ""); err != nil { if _, err = store.QueueAgentAction("a", "system-update", ""); err != nil {
t.Fatalf("system update rejected: %v", err) t.Fatalf("system update rejected: %v", err)
} }
action, err := store.QueueAgentAction("a", "systemd-restart", "gitea.service") if _, err = store.QueueAgentAction("a", "docker-remove", "gitea"); err == nil {
t.Fatal("running/unknown-state container accepted for removal")
}
action, err := store.QueueAgentAction("a", "docker-remove", "old")
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("stopped container rejected for removal: %v", err)
} }
commands, err := store.DispatchAgentActions("a") commands, err := store.DispatchAgentActions("a")
if err != nil || len(commands) != 3 || commands[2].ID != action.ID { if err != nil || len(commands) != 3 || commands[2].ID != action.ID {

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -25,6 +26,8 @@ type AgentProcess struct {
Name string `json:"name"` Name string `json:"name"`
State string `json:"state"` State string `json:"state"`
CPUPercent float64 `json:"cpuPercent"` CPUPercent float64 `json:"cpuPercent"`
CPUCores float64 `json:"cpuCores"`
HostPercent float64 `json:"hostPercent"`
MemoryBytes uint64 `json:"memoryBytes"` MemoryBytes uint64 `json:"memoryBytes"`
ReadBytes uint64 `json:"readBytes"` ReadBytes uint64 `json:"readBytes"`
WriteBytes uint64 `json:"writeBytes"` WriteBytes uint64 `json:"writeBytes"`
@@ -174,7 +177,11 @@ func collectAgentProcesses() []AgentProcess {
cpu, _ := strconv.ParseFloat(fields[3], 64) cpu, _ := strconv.ParseFloat(fields[3], 64)
rss, _ := strconv.ParseUint(fields[4], 10, 64) rss, _ := strconv.ParseUint(fields[4], 10, 64)
elapsed, _ := strconv.ParseInt(fields[5], 10, 64) elapsed, _ := strconv.ParseInt(fields[5], 10, 64)
item := AgentProcess{PID: pid, Name: fields[1], State: fields[2], CPUPercent: cpu, MemoryBytes: rss * 1024, Elapsed: elapsed, Command: strings.Join(fields[6:], " ")} cpuCount := runtime.NumCPU()
if cpuCount < 1 {
cpuCount = 1
}
item := AgentProcess{PID: pid, Name: fields[1], State: fields[2], CPUPercent: cpu, CPUCores: cpu / 100, HostPercent: cpu / float64(cpuCount), MemoryBytes: rss * 1024, Elapsed: elapsed, Command: strings.Join(fields[6:], " ")}
item.ReadBytes, item.WriteBytes = readAgentProcessIO(pid) item.ReadBytes, item.WriteBytes = readAgentProcessIO(pid)
result = append(result, item) result = append(result, item)
added++ added++

View File

@@ -53,6 +53,12 @@ func TestAgentOnlyInstallsNewerVersion(t *testing.T) {
} }
} }
func TestShortDockerDigest(t *testing.T) {
if got := shortDockerDigest("sha256:1234567890abcdef"); got != "1234567890ab" {
t.Fatalf("unexpected short digest: %q", got)
}
}
func TestParseAgentIssues(t *testing.T) { func TestParseAgentIssues(t *testing.T) {
issues := parseAgentIssues(`{"MESSAGE":"disk error","_SYSTEMD_UNIT":"worker.service","__REALTIME_TIMESTAMP":"1722960000000000"}` + "\n" + `{"MESSAGE":"network error","__REALTIME_TIMESTAMP":"1722960001000000"}`) issues := parseAgentIssues(`{"MESSAGE":"disk error","_SYSTEMD_UNIT":"worker.service","__REALTIME_TIMESTAMP":"1722960000000000"}` + "\n" + `{"MESSAGE":"network error","__REALTIME_TIMESTAMP":"1722960001000000"}`)
if len(issues) != 2 || issues[0].Unit != "worker.service" || issues[1].Unit != "system" || issues[0].Time != 1722960000 { if len(issues) != 2 || issues[0].Unit != "worker.service" || issues[1].Unit != "system" || issues[0].Time != 1722960000 {

10
web.go

File diff suppressed because one or more lines are too long