Clarify process CPU and improve Docker actions
This commit is contained in:
87
agent.go
87
agent.go
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user