134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
package updateagent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
|
)
|
|
|
|
type Runner interface {
|
|
Update(context.Context, updateprotocol.Request, func(phase, message string)) error
|
|
}
|
|
|
|
type Controller struct {
|
|
runner Runner
|
|
logger *slog.Logger
|
|
mu sync.RWMutex
|
|
status updateprotocol.Status
|
|
}
|
|
|
|
func NewController(runner Runner, logger *slog.Logger) *Controller {
|
|
return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}}
|
|
}
|
|
|
|
func (c *Controller) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /v1/status", c.statusHandler)
|
|
mux.HandleFunc("POST /v1/update", c.updateHandler)
|
|
return mux
|
|
}
|
|
|
|
func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(c.Status())
|
|
}
|
|
|
|
func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
|
defer r.Body.Close()
|
|
var request updateprotocol.Request
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&request); err != nil {
|
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
request.Version = strings.TrimSpace(request.Version)
|
|
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
|
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
|
|
if !updateprotocol.ValidVersion(request.Version) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
|
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !c.begin(request.Version) {
|
|
http.Error(w, "update already running", http.StatusConflict)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = w.Write([]byte("{\"accepted\":true}\n"))
|
|
go c.run(request)
|
|
}
|
|
|
|
func (c *Controller) begin(version string) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.status.Busy() {
|
|
return false
|
|
}
|
|
now := time.Now().UTC()
|
|
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now}
|
|
return true
|
|
}
|
|
|
|
func (c *Controller) run(request updateprotocol.Request) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
|
defer cancel()
|
|
err := c.runner.Update(ctx, request, c.setPhase)
|
|
registryToken := request.RegistryToken
|
|
request.RegistryToken = ""
|
|
if err != nil {
|
|
message := publicAgentError(err)
|
|
c.setPhase(updateprotocol.PhaseFailed, message)
|
|
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken))
|
|
return
|
|
}
|
|
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
|
|
c.logger.Info("software update completed", "version", request.Version)
|
|
}
|
|
|
|
func redactSecret(err error, secret string) error {
|
|
if err == nil || secret == "" || !strings.Contains(err.Error(), secret) {
|
|
return err
|
|
}
|
|
return errors.New(strings.ReplaceAll(err.Error(), secret, "[redacted]"))
|
|
}
|
|
|
|
func (c *Controller) setPhase(phase, message string) {
|
|
c.mu.Lock()
|
|
c.status.Phase = phase
|
|
c.status.Message = message
|
|
c.status.UpdatedAt = time.Now().UTC()
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *Controller) Status() updateprotocol.Status {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.status
|
|
}
|
|
|
|
func publicAgentError(err error) string {
|
|
if err == nil {
|
|
return "Update fehlgeschlagen"
|
|
}
|
|
var public interface{ PublicMessage() string }
|
|
if errors.As(err, &public) {
|
|
return public.PublicMessage()
|
|
}
|
|
return "Update fehlgeschlagen; der bisherige Container wurde wiederhergestellt"
|
|
}
|