Add backup maintenance and guest update controls

This commit is contained in:
Maxim
2026-08-21 21:15:52 +03:00
parent 714c29c449
commit f6498a2fc1
10 changed files with 268 additions and 32 deletions

View File

@@ -8,7 +8,9 @@ import (
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -61,7 +63,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
}
}
}
case "docker-restart":
case "docker-restart", "docker-check", "docker-update":
if safeContainerName.MatchString(target) {
for _, container := range report.Containers {
if container.ID == target || container.Name == target {
@@ -70,7 +72,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
}
}
}
case "updates-refresh":
case "updates-refresh", "system-update":
valid = target == ""
}
if !valid {
@@ -91,7 +93,9 @@ func (s *Store) DispatchAgentActions(agentID string) ([]AgentCommand, error) {
return nil, err
}
defer tx.Rollback()
_, _ = tx.Exec(`UPDATE agent_actions SET status='queued',started_at=NULL WHERE agent_id=? AND status='running' AND started_at<?`, agentID, time.Now().Add(-5*time.Minute).Unix())
// Обновление пакетов и Docker-образов может быть долгим. Не выдаём одну
// команду повторно, пока предыдущий запуск ещё имеет шанс завершиться.
_, _ = tx.Exec(`UPDATE agent_actions SET status='queued',started_at=NULL WHERE agent_id=? AND status='running' AND started_at<?`, agentID, time.Now().Add(-45*time.Minute).Unix())
rows, err := tx.Query(`SELECT id,action,target FROM agent_actions WHERE agent_id=? AND status='queued' ORDER BY id LIMIT 3`, agentID)
if err != nil {
return nil, err
@@ -160,6 +164,12 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
output = runAgentCombinedCommand(15*time.Second, "systemctl", "reset-failed", command.Target)
case "docker-restart":
output = restartAgentContainer(command.Target)
case "docker-update":
output = updateAgentContainer(command.Target, true)
case "docker-check":
output = updateAgentContainer(command.Target, false)
case "system-update":
output = updateAgentSystem()
case "updates-refresh":
guestInventory.Lock()
guestInventory.value.CollectedAt = time.Time{}
@@ -179,6 +189,77 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
return result
}
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:]
}
if err != nil {
return "ERROR: " + err.Error() + ": " + message
}
guestInventory.Lock()
guestInventory.value.CollectedAt = time.Time{}
guestInventory.Unlock()
return firstNonEmpty(message, "Обновления системы установлены")
}
func updateAgentContainer(target string, recreate bool) 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()
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
response, err := client.Get("http://docker/containers/" + target + "/json")
if err != nil {
return "ERROR: " + err.Error()
}
defer response.Body.Close()
var detail struct {
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"Config"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&detail) != nil {
return "ERROR: не удалось получить Compose-конфигурацию контейнера"
}
labels := detail.Config.Labels
service := labels["com.docker.compose.service"]
workdir := labels["com.docker.compose.project.working_dir"]
files := strings.Split(labels["com.docker.compose.project.config_files"], ",")
if service == "" || workdir == "" || len(files) == 0 || !filepath.IsAbs(workdir) || !filepath.IsAbs(files[0]) {
return "ERROR: безопасное обновление доступно только для Docker Compose"
}
if _, err = exec.LookPath("docker"); err != nil {
return "ERROR: Docker CLI не найден"
}
commands := [][]string{{"compose", "-f", files[0], "pull", service}}
if recreate {
commands = append(commands, []string{"compose", "-f", files[0], "up", "-d", "--no-deps", service})
}
for _, args := range commands {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
command := exec.CommandContext(ctx, "docker", args...)
command.Dir = workdir
output, commandErr := command.CombinedOutput()
cancel()
if commandErr != nil {
return "ERROR: " + commandErr.Error() + ": " + strings.TrimSpace(string(output))
}
}
if recreate {
return "Образ загружен, Compose-контейнер пересоздан"
}
return "Проверка завершена: образ загружен; если digest изменился, карточка покажет доступное обновление"
}
func runAgentCombinedCommand(timeout time.Duration, name string, args ...string) string {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()