feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
@@ -0,0 +1,133 @@
|
||||
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"
|
||||
}
|
||||
Reference in new issue
Block a user