Run guest updates outside agent sandbox

This commit is contained in:
Maxim
2026-08-21 22:09:35 +03:00
parent f764195ba5
commit 1296d052a5
3 changed files with 67 additions and 18 deletions

View File

@@ -8,7 +8,6 @@ import (
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
@@ -72,7 +71,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
}
}
}
case "updates-refresh", "system-update":
case "updates-refresh", "system-update", "system-reboot":
valid = target == ""
}
if !valid {
@@ -172,6 +171,8 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
output = removeAgentContainer(command.Target)
case "system-update":
output = updateAgentSystem()
case "system-reboot":
output = scheduleAgentReboot()
case "updates-refresh":
guestInventory.Lock()
guestInventory.value.CollectedAt = time.Time{}
@@ -210,25 +211,59 @@ func removeAgentContainer(target string) string {
}
func updateAgentSystem() string {
if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") {
return output
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
command := exec.CommandContext(ctx, "apt-get", "-y", "upgrade")
command.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
output, err := command.CombinedOutput()
message := strings.TrimSpace(string(output))
if len(message) > 3500 {
message = message[len(message)-3500:]
}
aptGet, err := exec.LookPath("apt-get")
if err != nil {
return "ERROR: " + err.Error() + ": " + message
return "ERROR: apt-get не найден; обновление поддерживается для Debian/Ubuntu"
}
if output := runPrivilegedTransient(5*time.Minute, "update-lists", aptGet, "update"); strings.HasPrefix(output, "ERROR: ") {
return explainSystemUpdateError(output)
}
message := runPrivilegedTransient(30*time.Minute, "upgrade", aptGet, "-y", "upgrade")
if strings.HasPrefix(message, "ERROR: ") {
return explainSystemUpdateError(message)
}
guestInventory.Lock()
guestInventory.value.CollectedAt = time.Time{}
guestInventory.Unlock()
return firstNonEmpty(message, "Обновления системы установлены")
return "Обновления системы установлены; список пакетов будет обновлён в следующем отчёте агента"
}
func explainSystemUpdateError(message string) string {
if strings.Contains(strings.ToLower(message), "read-only file system") {
return "ERROR: файловая система агента доступна только для чтения. Обновите Dashboard и агент до v0.19.2 или новее, затем повторите операцию."
}
if len(message) > 1800 {
message = message[len(message)-1800:]
}
return message
}
func runPrivilegedTransient(timeout time.Duration, suffix, name string, args ...string) string {
systemdRun, err := exec.LookPath("systemd-run")
if err != nil {
return "ERROR: systemd-run не найден; невозможно безопасно выйти из read-only sandbox агента"
}
unit := fmt.Sprintf("proxmox-dashboard-%s-%d", suffix, time.Now().UnixNano())
commandArgs := []string{"--quiet", "--wait", "--collect", "--pipe", "--unit=" + unit, "--setenv=DEBIAN_FRONTEND=noninteractive", name}
commandArgs = append(commandArgs, args...)
return runAgentCombinedCommand(timeout, systemdRun, commandArgs...)
}
func scheduleAgentReboot() string {
systemdRun, err := exec.LookPath("systemd-run")
if err != nil {
return "ERROR: systemd-run не найден"
}
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return "ERROR: systemctl не найден"
}
unit := fmt.Sprintf("proxmox-dashboard-reboot-%d", time.Now().UnixNano())
output := runAgentCombinedCommand(15*time.Second, systemdRun, "--quiet", "--collect", "--unit="+unit, "--on-active=20s", systemctl, "reboot")
if strings.HasPrefix(output, "ERROR: ") {
return output
}
return "Перезагрузка запланирована через 20 секунд; кратковременный offline агента ожидаем"
}
func updateAgentContainer(target string, recreate bool) string {

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
@@ -42,6 +43,12 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
if err != nil || len(items) != 3 || items[0].Status != "completed" {
t.Fatalf("items=%#v err=%v", items, err)
}
if _, err = store.QueueAgentAction("a", "system-reboot", ""); err != nil {
t.Fatalf("system reboot rejected: %v", err)
}
if _, err = store.QueueAgentAction("a", "system-reboot", "now"); err == nil {
t.Fatal("system reboot with unexpected target accepted")
}
}
func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) {
@@ -61,3 +68,10 @@ func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) {
t.Fatalf("group alert missing: %#v", grouped)
}
}
func TestExplainReadOnlySystemUpdateError(t *testing.T) {
message := explainSystemUpdateError("ERROR: apt failed: Read-only file system")
if !strings.Contains(message, "v0.19.2") {
t.Fatalf("read-only failure is not actionable: %s", message)
}
}

4
web.go

File diff suppressed because one or more lines are too long